chore(release): v0.9.29 with project-scope parity across capture surfaces (#1141)
* chore(release): v0.9.29 with project-scope parity across surfaces Version trio + plugin manifests + supportedVersions + ExportData union bumped to 0.9.29; CHANGELOG entry covering everything since v0.9.28 with upgrade notes for the four visible behavior changes. Fixes the endpoint-count drift on main (130 registered routes vs docs saying 129 after #1132 landed in parallel with #1136). Project-scope parity: OpenCode plugin, Hermes plugin, Pi extension, and JSONL replay now resolve project the same way the hooks do (env override, git toplevel basename, cwd basename) instead of sending raw filesystem paths, closing #903 and #1135 and pre-empting the same bug in pi. The filesystem watcher accepts AGENTMEMORY_PROJECT_NAME with the old AGENTMEMORY_PROJECT kept as a deprecated alias, replay handles Windows-recorded paths, and OpenCode file enrichment matches the agent's lowercase tool names (the capitalized set never matched). Tests: opencode fallback expectations updated to basenames per the canonicalization, git-toplevel resolution covered with a fixture repo, new project-scope-parity suite for replay and fs-watcher. * fix(release): review findings, git-toplevel parity, doc counts - skills generator dedupes routes on method plus path, so the REST reference lists all 130 registered routes instead of hiding the second method on ten dual-method paths (header said 119) - fs-watcher trims AGENTMEMORY_PROJECT_NAME and the deprecated alias, treating whitespace as unset, and derives the git toplevel basename when watching a subdirectory - replay resolves the git toplevel basename when the recorded cwd still exists locally (memoized per cwd), keeping the basename fallback for historical or cross-platform paths; no env override here since a bulk import spans many projects - parity tests for replay git-root resolution, watcher git-root and trim behavior - stat-tests badge updated from 1428+ to 1550+ passing * fix(cli): refuse second-instance boot over a live daemon Closes the class behind issue 1140: agentmemory consolidate (or any unrecognized word) fell through the command table into the full server boot, registering a duplicate worker on the running engine; on iii 0.11.2 the second instance's shutdown tears down the daemon's HTTP trigger routing until a full engine restart. Unknown subcommands now error with the supported list, and main() probes livez on the resolved port and refuses to boot over a live daemon, so multi-instance setups on other ports are unaffected. Verified behaviorally against the built CLI: both paths refuse with exit 1. Also from review: the watcher stamps each event with its own root's project via a per-root map (an explicit config.project still overrides for every root), and replay only accepts a non-empty string cwd from parsed JSONL so malformed entries cannot reach the filesystem probe. * test(watcher): two-repository flush events scope to their own project * chore(release): bump packages/mcp, guard it, refresh CONTRIBUTING packages/mcp was still 0.9.28 after the release bump because nothing guarded it; a consistency test now pins it to package.json. CONTRIBUTING release list corrected to the files a bump actually touches (no tracked lockfile, the two extra plugin manifests, the export test derives from VERSION now), and the subsystems table gains src/cli, integrations/pi, and the generated-manifest note. * fix(export): refuse over-frame export instead of dropping the worker Closes the availability bug in issue 1142: GET /agentmemory/export assembles the full store and returns it through sdk.trigger, so a store whose serialized export passes the engine's 16 MiB WebSocket frame (tungstenite max_frame_size, not raisable under the 0.11.2 pin) dies on the worker->engine hop, drops the worker, and 404s every endpoint for ~1s. The session collections page on maxSessions/offset but ~18 others do not, so a large store hits this at any parameter combination. A shared frame-guard measures the serialized size before returning: mem::export returns a small oversized error instead of the giant object, and api::mesh-export returns 413 (same dead-end as #890). Either way the over-frame payload never crosses the boundary, so the daemon stays up and the failure is one clean request with a hint to narrow the range. Full pagination of the non-session collections is a follow-up. Layer 1 of the fix; verified with a synthetic oversized export returning the error object (tiny) rather than the payload. * ci: collapse to a single npm install to fix Node 24/26 CI The two-step install (npm install --package-lock-only then npm ci) failed only on the Node 24/26 matrix rows: their stricter npm rejects rolldown's optional platform bindings (@rolldown/binding-android-arm64) that a --package-lock-only pass does not fully enumerate. Lockfiles are gitignored, so npm ci re-validation buys no reproducibility here. A single lenient npm install resolves and installs in one pass. * fix(mesh): scope exported memories by project like actions api::mesh-export filtered actions by ?project but returned every project's memories. On a mesh instance federating one project to a peer, the peer pulled other projects' memories (cross-project leak), and those extras could push the payload past the 16 MiB transport frame into a 413 even when the requested project's own slice fit. Memories carry the same optional project field as actions, so filter both before the frame-size guard runs. Adds a regression test asserting a project-scoped export excludes other projects' memories and that an oversized memory in another project no longer 413s the scoped request. * chore(release): credit the Antigravity native hooks adapter in 0.9.29 notes * chore(release): sweep stale 0.9.28 refs for 0.9.29 Deploy Dockerfiles/compose/render pins, AGENTS.md stats header, opencode plugin manifest, website meta snapshot, test-count claims (1,428 -> 1,596) in README/AGENTS/stat SVGs, and the missing 0.9.29 CHANGELOG compare link. * chore(release): sync stat-tests badge to 1596+ and commit bridge exec bit * refactor: trim frame-guard comments and drop issue refs from code
This commit is contained in:
@@ -60,11 +60,13 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
# Two-step install: generate a lockfile in-runner with
|
||||
# --package-lock-only, then install from it with `npm ci`.
|
||||
# Lockfiles are gitignored at the repo level.
|
||||
- run: npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund
|
||||
- run: npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
# Lockfiles are gitignored, so `npm ci` (which strictly re-validates a
|
||||
# committed lockfile) buys no reproducibility here — and Node 24+'s
|
||||
# stricter npm rejects rolldown's optional platform bindings that a
|
||||
# `--package-lock-only` pass doesn't fully enumerate, failing the matrix
|
||||
# on 24/26 only. A single lenient `npm install` resolves and installs
|
||||
# in one pass.
|
||||
- run: npm install --legacy-peer-deps --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- run: npm run skills:check
|
||||
- run: npm test
|
||||
|
||||
@@ -109,16 +109,16 @@ Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import).
|
||||
|
||||
## Testing
|
||||
|
||||
- All tests must pass before PR: `npm test` (1,428+ tests)
|
||||
- All tests must pass before PR: `npm test` (1,596+ tests)
|
||||
- Mock pattern: `vi.mock("iii-sdk")` with mock `sdk.trigger`, `kv.get/set/list`
|
||||
- Test files go in `test/` with `.test.ts` extension
|
||||
- Follow existing patterns in `test/crystallize.test.ts` for function tests
|
||||
|
||||
## Current Stats (v0.9.28)
|
||||
## Current Stats (v0.9.29)
|
||||
|
||||
- 54 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all)
|
||||
- 129 REST endpoints
|
||||
- 130 REST endpoints
|
||||
- 6 MCP resources, 3 MCP prompts
|
||||
- 12 hooks, 15 skills
|
||||
- 260+ iii functions
|
||||
- 1,428+ tests
|
||||
- 1,596+ tests
|
||||
|
||||
@@ -6,6 +6,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.9.29] — 2026-08-02
|
||||
|
||||
Patch release: the `.env` file now actually applies everywhere, imports become searchable, consolidation runs on session stop, twelve MCP-only agents get activated on connect, and every capture surface finally agrees on what "project" means. No breaking changes; read the upgrade notes below for four behavior changes you will notice.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- `~/.agentmemory/.env` values that were silently ignored by most modules now take effect on boot. If that file has stale entries from past experiments, review it before upgrading.
|
||||
- `agentmemory connect <agent>` now writes a short memory-usage guideline into the agent's native rules file (Cursor, Cline, Continue, Zed, Warp, Kiro, Gemini CLI, Qwen, OpenCode, Droid, Copilot CLI, Antigravity) so MCP-only agents actually call the memory tools. Pass `--no-guidelines` to opt out.
|
||||
- Installs with an LLM key now run consolidation and crystallization on session stop (previously they never fired), debounced to once per 5 minutes (`AGENTMEMORY_CONSOLIDATION_COOLDOWN_MS`).
|
||||
- Local embeddings re-download once after the `@huggingface/transformers` migration (different model cache directory). Model IDs are unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
- `--data-dir` flag and `AGENTMEMORY_DATA_DIR` so iii-engine state lives outside repositories, with gated legacy `./data` adoption and Docker-volume preservation (#314)
|
||||
- Native hooks adapter for Droid via `~/.factory/hooks.json`, reusing the bundled hook scripts (#1130)
|
||||
- Native hooks adapter for Antigravity CLI (agy) via a stdin bridge that normalizes agy's hook payloads onto the bundled hook scripts, with an explicit PreToolUse allow decision (#1146, thanks @berthojoris)
|
||||
- `mem::graph::import-graphify` and `POST /agentmemory/graph/import-graphify`: merge graphify's `graph.json` into the knowledge graph with confidence tags carried over as edge weights (#1136)
|
||||
- Connector guideline activation for twelve hook-less agents, with every rules-file path verified against the agent's official documentation (#1136)
|
||||
- Honest `memory_forget` reporting plus a real lesson delete path (`mem::lesson-delete`, `DELETE`-style REST route, MCP tool) (#1132)
|
||||
- `AGENTMEMORY_PROJECT_NAME` override in the OpenCode plugin (#1125)
|
||||
- Provider fetches retry 429/503 honoring `Retry-After` under a total-elapsed budget capped below the iii invocation timeout (#1136)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Boot hydrates `~/.agentmemory/.env` into `process.env`, closing the class of "env var in .env is ignored" bugs (#1136)
|
||||
- Imported and replayed observations are indexed into BM25 and the vector index, so imports are searchable (#1072, via #1136)
|
||||
- Snapshot timer actually runs, non-positive intervals clamp to the default, and snapshot creation is serialized across timer, REST, and MCP (#1006, via #1136)
|
||||
- CJK-aware dedup with NFC normalization and an exact-match fallback for short memories (#1021, via #1136)
|
||||
- OpenRouter embeddings no longer hardcode 1536 dimensions (#1002, via #1136)
|
||||
- Viewer decodes multibyte request bodies correctly (#930, via #1136)
|
||||
- Session-stop consolidation is debounced and no longer double-fires from the client hook; eviction recovery is bounded to one consolidation pass (#1087, #1131 class, via #1136)
|
||||
- `/agentmemory/sessions` no longer deadlocks on large session counts (#1100, via #1136)
|
||||
- Filesystem watcher validates roots before `fs.watch`, fixing Node 24/26 on Linux (#1136)
|
||||
- `GET /agentmemory/export` and `/agentmemory/mesh/export` refuse an over-frame response instead of shipping it: a payload past the engine 16 MiB transport frame used to drop the worker and 404 every endpoint for ~1s. They now fail that one request (413 for mesh, an `oversized` error for export) with a hint to narrow the range, keeping the daemon up (#1142, #890). Full pagination of the non-session collections is a follow-up.
|
||||
- Claude bridge writes `MEMORY.md` under the `memory/` subdirectory Claude Code actually reads (#1134)
|
||||
- Hook project-resolution tests no longer depend on the checkout directory name (#1137, #1138)
|
||||
- Project-scope parity: the OpenCode plugin, Hermes plugin, Pi extension, and JSONL replay now resolve `project` the same way the hooks do (env override, git toplevel basename, cwd basename) instead of sending raw filesystem paths, so the same repository shares one memory bucket across agents (#903, #1135); the filesystem watcher accepts `AGENTMEMORY_PROJECT_NAME` with the old `AGENTMEMORY_PROJECT` kept as a deprecated alias; replay handles Windows-recorded paths
|
||||
- OpenCode file enrichment matches the agent's lowercase tool names, which the previous capitalized set never did
|
||||
- Viewer surfaces health status from non-2xx health responses (#1046)
|
||||
- Documented REST endpoint count matches the registered routes again (130)
|
||||
|
||||
### Changed
|
||||
|
||||
- Local embeddings migrate from `@xenova/transformers` to `@huggingface/transformers` v4 with Node 22+ support; CI now tests Node 20, 22, 24, and 26 (#479, #1096)
|
||||
|
||||
## [0.9.28] — 2026-07-19
|
||||
|
||||
Patch release: hardens the hook runner against malformed payloads and closes a cross-agent context leak. No breaking changes; drop-in upgrade.
|
||||
@@ -66,6 +111,7 @@ Wave release closing several breaking regressions reported against v0.9.26, plus
|
||||
- `/agentmemory:forget` skill still calls `memory_governance_delete` which only touches `KV.memories` and never observations ([#833](https://github.com/rohitg00/agentmemory/issues/833)). Skill rewrite + new `memory_forget` MCP tool tracked separately.
|
||||
- `crypto.randomUUID()` global-only on Node <19 ([#715](https://github.com/rohitg00/agentmemory/issues/715)). Drop-in import fix tracked.
|
||||
|
||||
[0.9.29]: https://github.com/rohitg00/agentmemory/compare/v0.9.28...v0.9.29
|
||||
[0.9.28]: https://github.com/rohitg00/agentmemory/compare/v0.9.27...v0.9.28
|
||||
[0.9.27]: https://github.com/rohitg00/agentmemory/compare/v0.9.26...v0.9.27
|
||||
|
||||
|
||||
+13
-10
@@ -68,10 +68,11 @@ PRs with commits lacking sign-off will not merge.
|
||||
| `src/mcp/` | Standalone MCP server (`@agentmemory/mcp`), tools registry, transport, in-memory KV. |
|
||||
| `src/functions/` | Core memory operations — observe, compress, consolidate, retention, forget, graph, smart-search, export-import, governance. |
|
||||
| `src/hooks/` | The 12 auto-hooks that capture sessions in agents. |
|
||||
| `src/cli/` | The `agentmemory` CLI, including `connect/` adapters for 18 agents and the guideline writer for hook-less agents. |
|
||||
| `src/health/` | Liveness + readiness + alert thresholds. |
|
||||
| `src/state/` | KV schema, keyed mutex, access log. |
|
||||
| `integrations/` | First-party plugins: `hermes/`, `openclaw/`, `filesystem-watcher/`. |
|
||||
| `plugin/` | Claude Code plugin (`agentmemory@agentmemory`). |
|
||||
| `integrations/` | First-party plugins: `hermes/`, `openclaw/`, `pi/`, `filesystem-watcher/`. |
|
||||
| `plugin/` | Agent plugin bundle: Claude Code plugin, hook manifests for Codex/Copilot/Droid, the OpenCode capture plugin, and the skills. Hook manifests and skill REFERENCE files are partly generated; run `npm run skills:gen` after touching registered endpoints or env vars. |
|
||||
| `website/` | Marketing site (Next.js 16). |
|
||||
| `test/` | Vitest test suite. |
|
||||
|
||||
@@ -92,18 +93,20 @@ PRs with commits lacking sign-off will not merge.
|
||||
|
||||
## Release process
|
||||
|
||||
Maintainers cut releases. Every bump touches 8 files in lockstep:
|
||||
Maintainers cut releases. Every bump touches these files in lockstep (the consistency tests fail if the trio of doc counts or any version drifts):
|
||||
|
||||
1. `package.json`
|
||||
2. `package-lock.json` (top + `packages[""].version`)
|
||||
2. `src/version.ts`
|
||||
3. `plugin/.claude-plugin/plugin.json`
|
||||
4. `packages/mcp/package.json` (self + `~x.y.z` pin on the main package)
|
||||
5. `src/version.ts` (extend the union, assign)
|
||||
6. `src/types.ts` (`ExportData.version` union)
|
||||
7. `src/functions/export-import.ts` (`supportedVersions` Set)
|
||||
8. `test/export-import.test.ts` (assertion)
|
||||
4. `plugin/plugin.json`
|
||||
5. `plugin/.codex-plugin/plugin.json`
|
||||
6. `packages/mcp/package.json`
|
||||
7. `src/types.ts` (`ExportData.version` union)
|
||||
8. `src/functions/export-import.ts` (`supportedVersions` Set)
|
||||
|
||||
Then: CHANGELOG section, PR, merge, tag, GitHub release. The `Publish to npm` workflow picks up the release trigger and publishes `@agentmemory/agentmemory`, `@agentmemory/mcp`, and `@agentmemory/fs-watcher` to npm with provenance.
|
||||
No lockfiles are committed. `test/export-import.test.ts` asserts against the `VERSION` constant, so it needs no per-release edit. Run `npm run skills:gen` if the endpoint or env surface changed.
|
||||
|
||||
Then: CHANGELOG section, PR, merge, tag, GitHub release. The `Publish to npm` workflow picks up the release trigger and publishes `@agentmemory/agentmemory`, `@agentmemory/mcp`, and `@agentmemory/fs-watcher` to npm with provenance (`@agentmemory/fs-watcher` versions independently from `integrations/filesystem-watcher/package.json`).
|
||||
|
||||
## Security issues
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/stat-tools.svg"><img src="assets/tags/stat-tools.svg" alt="54 MCP tools" height="38" /></picture>
|
||||
<picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/stat-hooks.svg"><img src="assets/tags/stat-hooks.svg" alt="12 auto hooks" height="38" /></picture>
|
||||
<picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/stat-deps.svg"><img src="assets/tags/stat-deps.svg" alt="0 external DBs" height="38" /></picture>
|
||||
<picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/stat-tests.svg"><img src="assets/tags/stat-tests.svg" alt="1,428+ tests passing" height="38" /></picture>
|
||||
<picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/stat-tests.svg"><img src="assets/tags/stat-tests.svg" alt="1,596+ tests passing" height="38" /></picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -1209,7 +1209,7 @@ Full registry: [workers.iii.dev](https://workers.iii.dev). Every worker there co
|
||||
| Prometheus / Grafana | iii OTEL + health monitor |
|
||||
| Custom plugin systems | `iii worker add <name>` |
|
||||
|
||||
**175 source files · ~39,200 LOC · 1,428+ tests · 261 functions · 52 KV scopes** — all on three primitives. No `agentmemory plugin install`. The plugin system is iii itself.
|
||||
**175 source files · ~39,200 LOC · 1,596+ tests · 261 functions · 52 KV scopes** — all on three primitives. No `agentmemory plugin install`. The plugin system is iii itself.
|
||||
|
||||
---
|
||||
|
||||
@@ -1499,7 +1499,7 @@ Create `~/.agentmemory/.env`:
|
||||
|
||||
<h2 id="api"><picture><source media="(prefers-color-scheme: dark)" srcset="assets/tags/light/section-api.svg"><img src="assets/tags/section-api.svg" alt="API" height="32" /></picture></h2>
|
||||
|
||||
129 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer <secret>` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
|
||||
130 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer <secret>` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
|
||||
|
||||
<details>
|
||||
<summary>Key endpoints</summary>
|
||||
@@ -1533,7 +1533,7 @@ Full endpoint list: [`src/triggers/api.ts`](src/triggers/api.ts)
|
||||
```bash
|
||||
npm run dev # Hot reload
|
||||
npm run build # Production build
|
||||
npm test # 1,428+ tests
|
||||
npm test # 1,596+ tests
|
||||
npm run test:integration # API tests (requires running services)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 48" fill="none" role="img" aria-label="TESTS PASSING: 1428+">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 48" fill="none" role="img" aria-label="TESTS PASSING: 1596+">
|
||||
<rect x="0" y="0" width="140" height="48" rx="10" fill="#FFFFFF" stroke="#E5E7EB" stroke-width="1"/>
|
||||
<text x="70" y="24" fill="#16A34A" font-family='ui-sans-serif, -apple-system, "Segoe UI", Inter, system-ui, sans-serif' font-size="18" font-weight="900" text-anchor="middle" letter-spacing="-0.3">1428+</text>
|
||||
<text x="70" y="24" fill="#16A34A" font-family='ui-sans-serif, -apple-system, "Segoe UI", Inter, system-ui, sans-serif' font-size="18" font-weight="900" text-anchor="middle" letter-spacing="-0.3">1596+</text>
|
||||
<text x="70" y="38" fill="#64748B" font-family='ui-sans-serif, -apple-system, "Segoe UI", Inter, system-ui, sans-serif' font-size="9" font-weight="600" text-anchor="middle" letter-spacing="1.5">TESTS PASSING</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 657 B After Width: | Height: | Size: 657 B |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 48" fill="none" role="img" aria-label="TESTS PASSING: 1428+">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 140 48" fill="none" role="img" aria-label="TESTS PASSING: 1596+">
|
||||
<rect x="0" y="0" width="140" height="48" rx="10" fill="#1A1A1A" stroke="#2A2A2A" stroke-width="1"/>
|
||||
<text x="70" y="24" fill="#00D26A" font-family='ui-sans-serif, -apple-system, "Segoe UI", Inter, system-ui, sans-serif' font-size="18" font-weight="900" text-anchor="middle" letter-spacing="-0.3">1428+</text>
|
||||
<text x="70" y="24" fill="#00D26A" font-family='ui-sans-serif, -apple-system, "Segoe UI", Inter, system-ui, sans-serif' font-size="18" font-weight="900" text-anchor="middle" letter-spacing="-0.3">1596+</text>
|
||||
<text x="70" y="38" fill="#9CA3AF" font-family='ui-sans-serif, -apple-system, "Segoe UI", Inter, system-ui, sans-serif' font-size="9" font-weight="600" text-anchor="middle" letter-spacing="1.5">TESTS PASSING</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 657 B After Width: | Height: | Size: 657 B |
@@ -4,7 +4,7 @@ FROM iiidev/iii:${III_VERSION} AS iii-image
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
ARG AGENTMEMORY_VERSION=0.9.28
|
||||
ARG AGENTMEMORY_VERSION=0.9.29
|
||||
ARG III_VERSION=0.11.2
|
||||
ARG III_SDK_VERSION=0.11.2
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
AGENTMEMORY_VERSION: "0.9.28"
|
||||
AGENTMEMORY_VERSION: "0.9.29"
|
||||
III_VERSION: "0.11.2"
|
||||
III_SDK_VERSION: "0.11.2"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM iiidev/iii:${III_VERSION} AS iii-image
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
ARG AGENTMEMORY_VERSION=0.9.28
|
||||
ARG AGENTMEMORY_VERSION=0.9.29
|
||||
ARG III_VERSION=0.11.2
|
||||
ARG III_SDK_VERSION=0.11.2
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM iiidev/iii:${III_VERSION} AS iii-image
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
ARG AGENTMEMORY_VERSION=0.9.28
|
||||
ARG AGENTMEMORY_VERSION=0.9.29
|
||||
ARG III_VERSION=0.11.2
|
||||
ARG III_SDK_VERSION=0.11.2
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM iiidev/iii:${III_VERSION} AS iii-image
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
ARG AGENTMEMORY_VERSION=0.9.28
|
||||
ARG AGENTMEMORY_VERSION=0.9.29
|
||||
ARG III_VERSION=0.11.2
|
||||
ARG III_SDK_VERSION=0.11.2
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- key: PORT
|
||||
value: "3111"
|
||||
- key: AGENTMEMORY_VERSION
|
||||
value: "0.9.28"
|
||||
value: "0.9.29"
|
||||
- key: III_VERSION
|
||||
value: "0.11.2"
|
||||
- key: III_SDK_VERSION
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { watch, promises as fsp, statSync } from "node:fs";
|
||||
import { resolve, relative, join, extname, sep, basename } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
// Same resolution order as the hooks' resolveProject (git toplevel basename,
|
||||
// then directory basename) so a watched subdirectory scopes to the repository
|
||||
// name instead of the subdirectory name.
|
||||
function deriveProjectName(dir) {
|
||||
try {
|
||||
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: dir,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (top) return basename(top);
|
||||
} catch {
|
||||
// not a git repo
|
||||
}
|
||||
return basename(dir);
|
||||
}
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
||||
@@ -123,7 +141,13 @@ export class FilesystemWatcher {
|
||||
this.secret = config.secret;
|
||||
this.project =
|
||||
config.project ||
|
||||
(this.roots[0] ? basename(this.roots[0]) : "filesystem-watcher");
|
||||
(this.roots[0] ? deriveProjectName(this.roots[0]) : "filesystem-watcher");
|
||||
// Per-root scope: a multi-root watcher must stamp each event with the
|
||||
// project of the root that produced it, not the first root's project.
|
||||
// An explicit config.project overrides for every root.
|
||||
this.projectByRoot = new Map(
|
||||
this.roots.map((r) => [r, config.project || deriveProjectName(r)]),
|
||||
);
|
||||
this.sessionId =
|
||||
config.sessionId ||
|
||||
`fs-watcher-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
||||
@@ -214,7 +238,7 @@ export class FilesystemWatcher {
|
||||
const payload = {
|
||||
hookType: "post_tool_use",
|
||||
sessionId: this.sessionId,
|
||||
project: this.project,
|
||||
project: this.projectByRoot.get(rootDir) ?? this.project,
|
||||
cwd: rootDir,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
@@ -319,7 +343,13 @@ export function configFromEnv(env = process.env) {
|
||||
roots,
|
||||
baseUrl: env.AGENTMEMORY_URL,
|
||||
secret: env.AGENTMEMORY_SECRET,
|
||||
project: env.AGENTMEMORY_PROJECT || null,
|
||||
// AGENTMEMORY_PROJECT_NAME is the canonical override (matches the hooks);
|
||||
// AGENTMEMORY_PROJECT stays as a deprecated alias for existing setups.
|
||||
// Trimmed, with whitespace-only treated as unset, same as resolveProject.
|
||||
project:
|
||||
(env.AGENTMEMORY_PROJECT_NAME || "").trim() ||
|
||||
(env.AGENTMEMORY_PROJECT || "").trim() ||
|
||||
null,
|
||||
sessionId: env.AGENTMEMORY_SESSION_ID || null,
|
||||
ignorePatterns: extraIgnore,
|
||||
allowBinary: env.AGENTMEMORY_FS_WATCH_ALLOW_BINARY === "1",
|
||||
|
||||
@@ -13,6 +13,30 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import subprocess
|
||||
from pathlib import PurePath
|
||||
|
||||
|
||||
def _resolve_project(cwd: str) -> str:
|
||||
"""Canonical project scope, matching the hooks' resolveProject order:
|
||||
AGENTMEMORY_PROJECT_NAME env override, git toplevel basename, cwd basename.
|
||||
Keeps Hermes sessions in the same project bucket as every other agent."""
|
||||
explicit = os.environ.get("AGENTMEMORY_PROJECT_NAME", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
try:
|
||||
top = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
).stdout.strip()
|
||||
if top:
|
||||
return PurePath(top).name
|
||||
except Exception:
|
||||
pass
|
||||
return PurePath(cwd).name or cwd
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
@@ -188,14 +212,15 @@ class AgentMemoryProvider(MemoryProvider):
|
||||
def initialize(self, session_id: str, **kwargs: Any) -> None:
|
||||
self._base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL)
|
||||
self._session_id = session_id
|
||||
self._project = kwargs.get("cwd", os.getcwd())
|
||||
self._cwd = kwargs.get("cwd", os.getcwd())
|
||||
self._project = _resolve_project(self._cwd)
|
||||
if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1":
|
||||
_check_plaintext_bearer_guard(self._base, os.environ.get("AGENTMEMORY_SECRET", ""))
|
||||
|
||||
_api(self._base, "session/start", {
|
||||
"sessionId": session_id,
|
||||
"project": self._project,
|
||||
"cwd": self._project,
|
||||
"cwd": self._cwd,
|
||||
})
|
||||
|
||||
def get_config_schema(self) -> list[dict]:
|
||||
@@ -348,7 +373,7 @@ class AgentMemoryProvider(MemoryProvider):
|
||||
"hookType": "post_tool_use",
|
||||
"sessionId": kwargs.get("session_id", self._session_id),
|
||||
"project": self._project,
|
||||
"cwd": self._project,
|
||||
"cwd": self._cwd,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"data": {
|
||||
"tool_name": "conversation",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createPlaintextBearerAuthGuard } from "./security.js";
|
||||
|
||||
type TextBlock = { type?: string; text?: string };
|
||||
@@ -120,7 +121,31 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
|
||||
);
|
||||
}
|
||||
let sessionId = `ephemeral-${crypto.randomUUID().slice(0, 8)}`;
|
||||
let currentProject = process.cwd();
|
||||
// Canonical project scope, matching the hooks' resolveProject order (env
|
||||
// override, git toplevel basename, cwd basename) so Pi sessions share a
|
||||
// project bucket with every other agent instead of scoping on a raw path.
|
||||
const projectCache = new Map<string, string>();
|
||||
function resolveProjectName(dir: string): string {
|
||||
const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]?.trim();
|
||||
if (explicit) return explicit;
|
||||
const cached = projectCache.get(dir);
|
||||
if (cached) return cached;
|
||||
let name = path.basename(dir) || dir;
|
||||
try {
|
||||
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: dir,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (top) name = path.basename(top);
|
||||
} catch {
|
||||
// not a git repo
|
||||
}
|
||||
projectCache.set(dir, name);
|
||||
return name;
|
||||
}
|
||||
let currentCwd = process.cwd();
|
||||
let currentProject = resolveProjectName(currentCwd);
|
||||
let lastPrompt = "";
|
||||
let lastHealthOk = false;
|
||||
|
||||
@@ -227,12 +252,14 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const sessionFile = ctx.sessionManager.getSessionFile();
|
||||
sessionId = sessionFile ? path.basename(sessionFile).replace(/\.[^.]+$/, "") : `ephemeral-${crypto.randomUUID().slice(0, 8)}`;
|
||||
currentProject = process.cwd();
|
||||
currentCwd = process.cwd();
|
||||
currentProject = resolveProjectName(currentCwd);
|
||||
await refreshStatus(ctx);
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
currentProject = event.systemPromptOptions.cwd || process.cwd();
|
||||
currentCwd = event.systemPromptOptions.cwd || process.cwd();
|
||||
currentProject = resolveProjectName(currentCwd);
|
||||
lastPrompt = event.prompt?.trim() || "";
|
||||
if (!lastPrompt) return;
|
||||
|
||||
@@ -262,7 +289,7 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
|
||||
hookType: "post_tool_use",
|
||||
sessionId,
|
||||
project: currentProject,
|
||||
cwd: currentProject,
|
||||
cwd: currentCwd,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
tool_name: "conversation",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@agentmemory/agentmemory",
|
||||
"version": "0.9.28",
|
||||
"version": "0.9.29",
|
||||
"description": "Persistent memory for AI coding agents, powered by iii-engine's three primitives",
|
||||
"type": "module",
|
||||
"main": "dist/index.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@agentmemory/mcp",
|
||||
"version": "0.9.28",
|
||||
"version": "0.9.29",
|
||||
"description": "Standalone MCP server for agentmemory — thin shim that re-exposes @agentmemory/agentmemory's MCP entrypoint",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agentmemory",
|
||||
"version": "0.9.28",
|
||||
"version": "0.9.29",
|
||||
"description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 54 MCP tools, 8 skills, real-time viewer.",
|
||||
"author": {
|
||||
"name": "Rohit Ghumare",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agentmemory",
|
||||
"version": "0.9.28",
|
||||
"version": "0.9.29",
|
||||
"description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 6 hooks, 54 MCP tools, 8 skills, real-time viewer.",
|
||||
"author": {
|
||||
"name": "Rohit Ghumare",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { basename } from "node:path";
|
||||
|
||||
const API = process.env.AGENTMEMORY_URL || "http://localhost:3111";
|
||||
const FILE_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]);
|
||||
// OpenCode reports tool names in lowercase ("read", "edit", ...); matching is
|
||||
// case-insensitive at the call site so a future casing change cannot silently
|
||||
// kill file enrichment again.
|
||||
const FILE_TOOLS = new Set(["read", "write", "edit", "glob", "grep"]);
|
||||
const FILE_KEYS = ["filePath", "file_path", "path", "file", "pattern"];
|
||||
const MAX_STASHED_FILES = 20;
|
||||
|
||||
@@ -50,8 +55,8 @@ async function observe(
|
||||
await post("/observe", {
|
||||
hookType,
|
||||
sessionId,
|
||||
project: projectPath,
|
||||
cwd: projectPath,
|
||||
project: projectName,
|
||||
cwd: projectCwd,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
});
|
||||
@@ -59,7 +64,28 @@ async function observe(
|
||||
|
||||
let activeSessionId: string | null = null;
|
||||
let pendingConfig: Record<string, unknown> | null = null;
|
||||
let projectPath: string | null = null;
|
||||
// projectName is the canonical scope (same resolution order as the hooks'
|
||||
// resolveProject: env override, git toplevel basename, cwd basename) so
|
||||
// OpenCode sessions land in the same project bucket as every other agent on
|
||||
// the repo. projectCwd keeps the full path for the cwd field.
|
||||
let projectName: string | null = null;
|
||||
let projectCwd: string | null = null;
|
||||
|
||||
function resolveProjectName(dir: string): string {
|
||||
const explicit = process.env.AGENTMEMORY_PROJECT_NAME?.trim();
|
||||
if (explicit) return explicit;
|
||||
try {
|
||||
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: dir,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (top) return basename(top);
|
||||
} catch {
|
||||
// not a git repo, fall through
|
||||
}
|
||||
return basename(dir) || dir;
|
||||
}
|
||||
const stashedFiles = new Map<string, Set<string>>();
|
||||
const seenSubtaskIds = new Map<string, Set<string>>();
|
||||
const seenToolCallIds = new Map<string, Set<string>>();
|
||||
@@ -168,8 +194,8 @@ function extractErrorMessage(err: unknown): string {
|
||||
}
|
||||
|
||||
export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
|
||||
const explicitProject = process.env.AGENTMEMORY_PROJECT_NAME?.trim();
|
||||
projectPath = explicitProject || ctx.worktree || ctx.project?.id || process.cwd();
|
||||
projectCwd = ctx.worktree || ctx.project?.id || process.cwd();
|
||||
projectName = resolveProjectName(projectCwd);
|
||||
|
||||
return {
|
||||
event: async ({ event }) => {
|
||||
@@ -194,8 +220,8 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
|
||||
title: info?.title ?? null,
|
||||
parentID: info?.parentID ?? null,
|
||||
version: info?.version ?? null,
|
||||
project: projectPath,
|
||||
cwd: projectPath,
|
||||
project: projectName,
|
||||
cwd: projectCwd,
|
||||
});
|
||||
// cache the context returned at session/start so the
|
||||
// chat.system.transform hook injects it without a second fetch.
|
||||
@@ -582,7 +608,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
|
||||
|
||||
// ── tool.execute.before ──
|
||||
"tool.execute.before": async (input, output) => {
|
||||
if (!FILE_TOOLS.has(input.tool)) return;
|
||||
if (!FILE_TOOLS.has(String(input.tool ?? "").toLowerCase())) return;
|
||||
const sid = input.sessionID || activeSessionId;
|
||||
if (!sid) return;
|
||||
const args = output.args as Record<string, unknown> | undefined;
|
||||
@@ -613,7 +639,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
|
||||
if (typeof ctx !== "string" || ctx.length === 0) {
|
||||
const result = await postJson("/context", {
|
||||
sessionId: sid,
|
||||
project: projectPath,
|
||||
project: projectName,
|
||||
});
|
||||
ctx = (result as any)?.context;
|
||||
} else {
|
||||
@@ -651,7 +677,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const result = await postJson("/context", {
|
||||
sessionId: sid,
|
||||
project: projectPath,
|
||||
project: projectName,
|
||||
});
|
||||
const ctx = (result as any)?.context;
|
||||
if (typeof ctx === "string" && ctx.length > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agentmemory-capture",
|
||||
"version": "0.9.20",
|
||||
"version": "0.9.29",
|
||||
"description": "OpenCode plugin for agentmemory — full Claude Code hook parity: session lifecycle (create/idle/status/compacted/update/diff/delete/error), messages & prompts (chat.message, message.updated user+assistant, message.removed), tool lifecycle (ToolPart states with timing), part tracking (subtask, step-finish, reasoning, file, patch, compaction, agent, retry), file enrichment pipeline, permissions, task tracking (w/ priority), commands, config & model tracking. 22 hooks, 2 slash commands.",
|
||||
"author": {
|
||||
"name": "Rohit Ghumare",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agentmemory",
|
||||
"version": "0.9.28",
|
||||
"version": "0.9.29",
|
||||
"description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 54 MCP tools, 15 skills, real-time viewer.",
|
||||
"author": {
|
||||
"name": "Rohit Ghumare",
|
||||
|
||||
Regular → Executable
@@ -5,10 +5,11 @@ Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run `
|
||||
<!-- AUTOGEN:rest START - generated by scripts/skills/generate.ts, do not edit by hand -->
|
||||
The REST API is the primary surface. All paths are under `http://localhost:3111` (override with `--port`). When `AGENTMEMORY_SECRET` is set, send `Authorization: Bearer $AGENTMEMORY_SECRET`; localhost is otherwise open.
|
||||
|
||||
119 registered endpoints:
|
||||
130 registered endpoints:
|
||||
|
||||
| Method | Path |
|
||||
| --- | --- |
|
||||
| GET | `/agentmemory/actions` |
|
||||
| POST | `/agentmemory/actions` |
|
||||
| POST | `/agentmemory/actions/edges` |
|
||||
| GET | `/agentmemory/actions/get` |
|
||||
@@ -19,6 +20,7 @@ The REST API is the primary surface. All paths are under `http://localhost:3111`
|
||||
| GET | `/agentmemory/branch/sessions` |
|
||||
| GET | `/agentmemory/branch/worktrees` |
|
||||
| POST | `/agentmemory/cascade-update` |
|
||||
| GET | `/agentmemory/checkpoints` |
|
||||
| POST | `/agentmemory/checkpoints` |
|
||||
| POST | `/agentmemory/checkpoints/resolve` |
|
||||
| GET | `/agentmemory/claude-bridge/read` |
|
||||
@@ -39,6 +41,7 @@ The REST API is the primary surface. All paths are under `http://localhost:3111`
|
||||
| POST | `/agentmemory/evict` |
|
||||
| POST | `/agentmemory/evolve` |
|
||||
| GET | `/agentmemory/export` |
|
||||
| GET | `/agentmemory/facets` |
|
||||
| POST | `/agentmemory/facets` |
|
||||
| POST | `/agentmemory/facets/query` |
|
||||
| POST | `/agentmemory/facets/remove` |
|
||||
@@ -64,6 +67,7 @@ The REST API is the primary surface. All paths are under `http://localhost:3111`
|
||||
| POST | `/agentmemory/leases/acquire` |
|
||||
| POST | `/agentmemory/leases/release` |
|
||||
| POST | `/agentmemory/leases/renew` |
|
||||
| GET | `/agentmemory/lessons` |
|
||||
| POST | `/agentmemory/lessons` |
|
||||
| POST | `/agentmemory/lessons/delete` |
|
||||
| POST | `/agentmemory/lessons/search` |
|
||||
@@ -72,6 +76,7 @@ The REST API is the primary surface. All paths are under `http://localhost:3111`
|
||||
| GET | `/agentmemory/memories` |
|
||||
| GET | `/agentmemory/memories/:id` |
|
||||
| GET | `/agentmemory/mesh/export` |
|
||||
| GET | `/agentmemory/mesh/peers` |
|
||||
| POST | `/agentmemory/mesh/peers` |
|
||||
| POST | `/agentmemory/mesh/receive` |
|
||||
| POST | `/agentmemory/mesh/sync` |
|
||||
@@ -84,16 +89,19 @@ The REST API is the primary surface. All paths are under `http://localhost:3111`
|
||||
| GET | `/agentmemory/procedural` |
|
||||
| GET | `/agentmemory/profile` |
|
||||
| POST | `/agentmemory/reflect` |
|
||||
| GET | `/agentmemory/relations` |
|
||||
| POST | `/agentmemory/relations` |
|
||||
| POST | `/agentmemory/remember` |
|
||||
| POST | `/agentmemory/replay/import-jsonl` |
|
||||
| GET | `/agentmemory/replay/load` |
|
||||
| GET | `/agentmemory/replay/sessions` |
|
||||
| GET | `/agentmemory/routines` |
|
||||
| POST | `/agentmemory/routines` |
|
||||
| POST | `/agentmemory/routines/run` |
|
||||
| GET | `/agentmemory/routines/status` |
|
||||
| POST | `/agentmemory/search` |
|
||||
| GET | `/agentmemory/semantic` |
|
||||
| GET | `/agentmemory/sentinels` |
|
||||
| POST | `/agentmemory/sentinels` |
|
||||
| POST | `/agentmemory/sentinels/cancel` |
|
||||
| POST | `/agentmemory/sentinels/check` |
|
||||
@@ -105,12 +113,15 @@ The REST API is the primary surface. All paths are under `http://localhost:3111`
|
||||
| GET | `/agentmemory/sessions` |
|
||||
| GET | `/agentmemory/signals` |
|
||||
| POST | `/agentmemory/signals/send` |
|
||||
| GET | `/agentmemory/sketches` |
|
||||
| POST | `/agentmemory/sketches` |
|
||||
| POST | `/agentmemory/sketches/add` |
|
||||
| POST | `/agentmemory/sketches/discard` |
|
||||
| POST | `/agentmemory/sketches/gc` |
|
||||
| POST | `/agentmemory/sketches/promote` |
|
||||
| DELETE | `/agentmemory/slot` |
|
||||
| GET | `/agentmemory/slot` |
|
||||
| POST | `/agentmemory/slot` |
|
||||
| POST | `/agentmemory/slot/append` |
|
||||
| POST | `/agentmemory/slot/reflect` |
|
||||
| POST | `/agentmemory/slot/replace` |
|
||||
|
||||
@@ -95,10 +95,16 @@ function rest(): string {
|
||||
const mm = /http_method:\s*"([A-Z]+)"/.exec(win);
|
||||
found.push({ path, method: mm ? mm[1] : "POST" });
|
||||
}
|
||||
// Dedupe on method+path, not path alone: ten paths register both GET and
|
||||
// POST, and a path-only dedupe hid the second method and undercounted the
|
||||
// surface (119 listed vs 130 registered).
|
||||
const seen = new Set<string>();
|
||||
const rows = found
|
||||
.filter((e) => (seen.has(e.path) ? false : (seen.add(e.path), true)))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
.filter((e) => {
|
||||
const key = `${e.method} ${e.path}`;
|
||||
return seen.has(key) ? false : (seen.add(key), true);
|
||||
})
|
||||
.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
||||
const lines = [
|
||||
`The REST API is the primary surface. All paths are under \`http://localhost:3111\` (override with \`--port\`). When \`AGENTMEMORY_SECRET\` is set, send \`Authorization: Bearer $AGENTMEMORY_SECRET\`; localhost is otherwise open.`,
|
||||
"",
|
||||
|
||||
+32
-1
@@ -1255,6 +1255,26 @@ function printReadyHint(consoleState: IiiConsoleState): void {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Booting a second instance next to a live daemon registers a duplicate
|
||||
// worker on the running engine, and on iii 0.11.2 the second instance's
|
||||
// shutdown tears down the daemon's HTTP trigger routing (every
|
||||
// /agentmemory/* route 404s until a full engine restart). Refuse instead.
|
||||
// A different --instance resolves to a different port, so multi-instance
|
||||
// setups are unaffected.
|
||||
try {
|
||||
const probe = await fetch(`${getBaseUrl()}/agentmemory/livez`, {
|
||||
signal: AbortSignal.timeout(1500),
|
||||
});
|
||||
if (probe.ok) {
|
||||
p.log.error(
|
||||
`agentmemory is already running on port ${getRestPort()}. Starting a second instance here would corrupt the running daemon's REST routing. Use the REST API (or the MCP tools) against the running instance, run a different --instance, or stop it first with \`agentmemory stop\`.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
// no live daemon on this port; boot normally
|
||||
}
|
||||
|
||||
// `--reset` wipes preferences before anything else so the onboarding
|
||||
// flow below always runs fresh.
|
||||
if (IS_RESET) {
|
||||
@@ -3049,7 +3069,18 @@ const commands: Record<string, () => Promise<void>> = {
|
||||
"import-jsonl": runImportJsonl,
|
||||
};
|
||||
|
||||
const handler = commands[args[0] ?? ""] ?? main;
|
||||
const first = args[0] ?? "";
|
||||
async function unknownCommand(): Promise<void> {
|
||||
p.log.error(
|
||||
`Unknown command: ${first}. Supported: ${Object.keys(commands).join(", ")}. Run \`agentmemory\` with no arguments to start the memory server, or \`agentmemory --help\` for usage.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Only a bare invocation or flag-style args boot the server; an unrecognized
|
||||
// word is an error. Previously any typo (or a guessed subcommand like
|
||||
// `agentmemory consolidate`) fell through to the full server boot and could
|
||||
// break a running daemon.
|
||||
const handler = commands[first] ?? (first && !first.startsWith("-") ? unknownCommand : main);
|
||||
handler().catch((err) => {
|
||||
p.log.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from "../types.js";
|
||||
import { normalizeAccessLog } from "./access-tracker.js";
|
||||
import { KV } from "../state/schema.js";
|
||||
import { checkPayloadFrameSize } from "../state/frame-guard.js";
|
||||
import { StateKV } from "../state/kv.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { recordAudit } from "./audit.js";
|
||||
@@ -181,6 +182,19 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
|
||||
summaries: summaries.length,
|
||||
});
|
||||
|
||||
// Only session collections page on ?maxSessions/?offset, so a large
|
||||
// store can exceed the transport cap even at ?maxSessions=1.
|
||||
const oversized = checkPayloadFrameSize(
|
||||
exportData,
|
||||
"narrow the range with ?maxSessions / ?offset, or export fewer collections; the non-session collections (memories, graph, semantic, actions, lessons, ...) are not yet paginated",
|
||||
);
|
||||
if (oversized) {
|
||||
logger.warn("Export exceeds transport frame limit", {
|
||||
bytes: oversized.bytes,
|
||||
});
|
||||
return oversized;
|
||||
}
|
||||
|
||||
return exportData;
|
||||
},
|
||||
);
|
||||
@@ -200,7 +214,7 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
|
||||
const strategy = data.strategy || "merge";
|
||||
const importData = data.exportData;
|
||||
|
||||
const supportedVersions = new Set(["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.6.1", "0.7.0", "0.7.2", "0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.9", "0.8.0", "0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8", "0.8.9", "0.8.10", "0.8.11", "0.8.12", "0.8.13", "0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5", "0.9.6", "0.9.7", "0.9.8", "0.9.9", "0.9.10", "0.9.11", "0.9.12", "0.9.13", "0.9.14", "0.9.15", "0.9.16", "0.9.17", "0.9.18", "0.9.19", "0.9.20", "0.9.21", "0.9.22", "0.9.23", "0.9.24", "0.9.25", "0.9.26", "0.9.27", "0.9.28"]);
|
||||
const supportedVersions = new Set(["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.6.1", "0.7.0", "0.7.2", "0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.9", "0.8.0", "0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8", "0.8.9", "0.8.10", "0.8.11", "0.8.12", "0.8.13", "0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5", "0.9.6", "0.9.7", "0.9.8", "0.9.9", "0.9.10", "0.9.11", "0.9.12", "0.9.13", "0.9.14", "0.9.15", "0.9.16", "0.9.17", "0.9.18", "0.9.19", "0.9.20", "0.9.21", "0.9.22", "0.9.23", "0.9.24", "0.9.25", "0.9.26", "0.9.27", "0.9.28", "0.9.29"]);
|
||||
if (!supportedVersions.has(importData.version)) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
+1
-1
@@ -540,7 +540,7 @@ async function main() {
|
||||
`Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`,
|
||||
);
|
||||
bootLog(
|
||||
`REST API: 129 endpoints at http://localhost:${config.restPort}/agentmemory/*`,
|
||||
`REST API: 130 endpoints at http://localhost:${config.restPort}/agentmemory/*`,
|
||||
);
|
||||
bootLog(
|
||||
`MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { HookType, RawObservation } from "../types.js";
|
||||
import { generateId } from "../state/schema.js";
|
||||
|
||||
@@ -24,10 +26,39 @@ export interface ParsedTranscript {
|
||||
observations: RawObservation[];
|
||||
}
|
||||
|
||||
// Memoized per import run: transcripts repeat the same cwd on every line.
|
||||
const projectByCwd = new Map<string, string>();
|
||||
|
||||
function deriveProject(cwd: string): string {
|
||||
if (!cwd) return "unknown";
|
||||
const parts = cwd.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || "unknown";
|
||||
const cached = projectByCwd.get(cwd);
|
||||
if (cached) return cached;
|
||||
let name = "";
|
||||
// When the recorded cwd still exists on this machine, resolve the git
|
||||
// toplevel basename so a subdirectory session scopes to the repository
|
||||
// name, matching the hooks' resolveProject. Historical or cross-platform
|
||||
// paths fall back to the basename below. No env override here: a bulk
|
||||
// import spans many projects, so a global name would mislabel them all.
|
||||
if (existsSync(cwd)) {
|
||||
try {
|
||||
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
if (top) name = top.split(/[\\/]+/).filter(Boolean).pop() ?? "";
|
||||
} catch {
|
||||
// not a git repo
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
// Split on both separators so a Windows-recorded cwd yields its basename
|
||||
// instead of the whole raw path becoming the project scope.
|
||||
const parts = cwd.split(/[\\/]+/).filter(Boolean);
|
||||
name = parts[parts.length - 1] || "unknown";
|
||||
}
|
||||
projectByCwd.set(cwd, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
function toText(content: unknown): string {
|
||||
@@ -99,7 +130,7 @@ export function parseJsonlText(text: string, fallbackSessionId?: string): Parsed
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.sessionId && !sessionId) sessionId = entry.sessionId;
|
||||
if (entry.cwd && !cwd) cwd = entry.cwd;
|
||||
if (typeof entry.cwd === "string" && entry.cwd.trim() && !cwd) cwd = entry.cwd;
|
||||
const ts = entry.timestamp || new Date().toISOString();
|
||||
if (!firstTs) firstTs = ts;
|
||||
lastTs = ts;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// The pinned engine rejects WebSocket frames over 16 MiB; an oversized
|
||||
// function result drops the worker and 404s every endpoint. Refuse the
|
||||
// payload as one clean error instead. The cap sits under the frame limit
|
||||
// to leave headroom for the SDK's framing overhead.
|
||||
const FRAME_LIMIT_BYTES = 16 * 1024 * 1024;
|
||||
export const SAFE_PAYLOAD_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
export type OversizedPayload = {
|
||||
success: false;
|
||||
error: string;
|
||||
oversized: true;
|
||||
bytes: number;
|
||||
limitBytes: number;
|
||||
};
|
||||
|
||||
export function payloadByteLength(payload: unknown): number {
|
||||
return Buffer.byteLength(JSON.stringify(payload) ?? "", "utf8");
|
||||
}
|
||||
|
||||
export function oversizedPayloadError(
|
||||
bytes: number,
|
||||
hint: string,
|
||||
): OversizedPayload {
|
||||
const mib = (bytes / (1024 * 1024)).toFixed(1);
|
||||
return {
|
||||
success: false,
|
||||
error: `Response is ${mib} MiB, over the ~${SAFE_PAYLOAD_BYTES / (1024 * 1024)} MiB engine transport frame limit; ${hint}`,
|
||||
oversized: true,
|
||||
bytes,
|
||||
limitBytes: SAFE_PAYLOAD_BYTES,
|
||||
};
|
||||
}
|
||||
|
||||
// Serializes once; callers that also return the payload pay a second
|
||||
// serialization, acceptable on these cold export paths.
|
||||
export function checkPayloadFrameSize(
|
||||
payload: unknown,
|
||||
hint: string,
|
||||
): OversizedPayload | null {
|
||||
const bytes = payloadByteLength(payload);
|
||||
if (bytes <= SAFE_PAYLOAD_BYTES) return null;
|
||||
return oversizedPayloadError(bytes, hint);
|
||||
}
|
||||
|
||||
export const FRAME_LIMIT_BYTES_FOR_TEST = FRAME_LIMIT_BYTES;
|
||||
+11
-1
@@ -2,6 +2,7 @@ import { TriggerAction, type ISdk, type ApiRequest } from "iii-sdk";
|
||||
import type { Session, CompressedObservation, HookPayload, CommitLink, SessionSummary } from "../types.js";
|
||||
import { withKeyedLock } from "../state/keyed-mutex.js";
|
||||
import { KV } from "../state/schema.js";
|
||||
import { checkPayloadFrameSize } from "../state/frame-guard.js";
|
||||
import { StateKV } from "../state/kv.js";
|
||||
import { getLatestHealth } from "../health/monitor.js";
|
||||
import type { MetricsStore } from "../eval/metrics-store.js";
|
||||
@@ -2766,9 +2767,10 @@ export function registerApiTriggers(
|
||||
const sinceTime = since ? new Date(since).getTime() : 0;
|
||||
const df = <T>(items: T[], field: "updatedAt" | "createdAt") =>
|
||||
items.filter((i) => new Date((i as Record<string, unknown>)[field] as string).getTime() > sinceTime);
|
||||
const memories = await kv.list<import("../types.js").Memory>(KV.memories);
|
||||
let memories = await kv.list<import("../types.js").Memory>(KV.memories);
|
||||
let actions = await kv.list<import("../types.js").Action>(KV.actions);
|
||||
if (project) {
|
||||
memories = memories.filter((m) => m.project === project);
|
||||
actions = actions.filter((a) => a.project === project);
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
@@ -2789,6 +2791,14 @@ export function registerApiTriggers(
|
||||
);
|
||||
body.graphEdges = df(graphEdges, "createdAt");
|
||||
}
|
||||
// Fail an over-frame export with 413 instead of dropping the worker.
|
||||
const oversized = checkPayloadFrameSize(
|
||||
body,
|
||||
"use ?since to fetch only changes after a timestamp, or ?project to scope the export",
|
||||
);
|
||||
if (oversized) {
|
||||
return { status_code: 413, body: oversized };
|
||||
}
|
||||
return { status_code: 200, body };
|
||||
},
|
||||
);
|
||||
|
||||
+1
-1
@@ -307,7 +307,7 @@ export interface ExportPagination {
|
||||
}
|
||||
|
||||
export interface ExportData {
|
||||
version: "0.3.0" | "0.4.0" | "0.5.0" | "0.6.0" | "0.6.1" | "0.7.0" | "0.7.2" | "0.7.3" | "0.7.4" | "0.7.5" | "0.7.6" | "0.7.7" | "0.7.9" | "0.8.0" | "0.8.1" | "0.8.2" | "0.8.3" | "0.8.4" | "0.8.5" | "0.8.6" | "0.8.7" | "0.8.8" | "0.8.9" | "0.8.10" | "0.8.11" | "0.8.12" | "0.8.13" | "0.9.0" | "0.9.1" | "0.9.2" | "0.9.3" | "0.9.4" | "0.9.5" | "0.9.6" | "0.9.7" | "0.9.8" | "0.9.9" | "0.9.10" | "0.9.11" | "0.9.12" | "0.9.13" | "0.9.14" | "0.9.15" | "0.9.16" | "0.9.17" | "0.9.18" | "0.9.19" | "0.9.20" | "0.9.21" | "0.9.22" | "0.9.23" | "0.9.24" | "0.9.25" | "0.9.26" | "0.9.27" | "0.9.28";
|
||||
version: "0.3.0" | "0.4.0" | "0.5.0" | "0.6.0" | "0.6.1" | "0.7.0" | "0.7.2" | "0.7.3" | "0.7.4" | "0.7.5" | "0.7.6" | "0.7.7" | "0.7.9" | "0.8.0" | "0.8.1" | "0.8.2" | "0.8.3" | "0.8.4" | "0.8.5" | "0.8.6" | "0.8.7" | "0.8.8" | "0.8.9" | "0.8.10" | "0.8.11" | "0.8.12" | "0.8.13" | "0.9.0" | "0.9.1" | "0.9.2" | "0.9.3" | "0.9.4" | "0.9.5" | "0.9.6" | "0.9.7" | "0.9.8" | "0.9.9" | "0.9.10" | "0.9.11" | "0.9.12" | "0.9.13" | "0.9.14" | "0.9.15" | "0.9.16" | "0.9.17" | "0.9.18" | "0.9.19" | "0.9.20" | "0.9.21" | "0.9.22" | "0.9.23" | "0.9.24" | "0.9.25" | "0.9.26" | "0.9.27" | "0.9.28" | "0.9.29";
|
||||
exportedAt: string;
|
||||
sessions: Session[];
|
||||
observations: Record<string, CompressedObservation[]>;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = "0.9.28";
|
||||
export const VERSION = "0.9.29";
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// A second full instance next to a live daemon registers a duplicate worker
|
||||
// on the running engine, and on iii 0.11.2 its shutdown tears down the
|
||||
// daemon's HTTP trigger routing (every /agentmemory/* route 404s until a full
|
||||
// engine restart). Two guards prevent that: unknown subcommands error instead
|
||||
// of falling through to the server boot, and the boot path probes livez and
|
||||
// refuses when a live daemon already answers on the resolved port.
|
||||
describe("CLI second-instance guards (#1140)", () => {
|
||||
const src = readFileSync("src/cli.ts", "utf-8");
|
||||
|
||||
it("unknown subcommands do not fall through to the server boot", () => {
|
||||
expect(src).toContain("async function unknownCommand()");
|
||||
expect(src).toMatch(
|
||||
/const handler = commands\[first\] \?\? \(first && !first\.startsWith\("-"\) \? unknownCommand : main\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("main() probes livez and refuses to boot over a live daemon", () => {
|
||||
const mainBody = src.slice(src.indexOf("async function main()"));
|
||||
const probeIdx = mainBody.indexOf("/agentmemory/livez");
|
||||
expect(probeIdx).toBeGreaterThan(-1);
|
||||
// The probe must run before the engine/worker boot path.
|
||||
const bootIdx = mainBody.indexOf("startEngine");
|
||||
expect(probeIdx).toBeLessThan(bootIdx);
|
||||
expect(mainBody).toContain("already running on port");
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,15 @@ describe("Consistency checks", () => {
|
||||
expect(plugin.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it("packages/mcp version matches package.json", () => {
|
||||
// The mcp package publishes in lockstep with the main package but its
|
||||
// version lives in its own manifest; without this guard a release bump
|
||||
// can silently ship a stale @agentmemory/mcp (it slipped in 0.9.29).
|
||||
const pkg = JSON.parse(readText("package.json"));
|
||||
const mcp = JSON.parse(readText("packages/mcp/package.json"));
|
||||
expect(mcp.version).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it("export-import.ts supports current version", () => {
|
||||
const src = readText("src/functions/export-import.ts");
|
||||
expect(src).toContain(`"${VERSION}"`);
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock("../src/logger.js", () => ({
|
||||
}));
|
||||
|
||||
import { registerExportImportFunction } from "../src/functions/export-import.js";
|
||||
import { VERSION } from "../src/version.js";
|
||||
import { getSearchIndex } from "../src/functions/search.js";
|
||||
import type {
|
||||
Session,
|
||||
@@ -124,7 +125,7 @@ describe("Export/Import Functions", () => {
|
||||
it("export produces valid ExportData structure", async () => {
|
||||
const result = (await sdk.trigger("mem::export", {})) as ExportData;
|
||||
|
||||
expect(result.version).toBe("0.9.28");
|
||||
expect(result.version).toBe(VERSION);
|
||||
expect(result.exportedAt).toBeDefined();
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].id).toBe("ses_1");
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/logger.js", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import {
|
||||
checkPayloadFrameSize,
|
||||
oversizedPayloadError,
|
||||
payloadByteLength,
|
||||
SAFE_PAYLOAD_BYTES,
|
||||
FRAME_LIMIT_BYTES_FOR_TEST,
|
||||
} from "../src/state/frame-guard.js";
|
||||
import { registerExportImportFunction } from "../src/functions/export-import.js";
|
||||
import { KV } from "../src/state/schema.js";
|
||||
import type { Session } from "../src/types.js";
|
||||
|
||||
// The guard must catch an oversized payload before the return so the frame
|
||||
// that would drop the worker is never shipped.
|
||||
|
||||
describe("frame-guard", () => {
|
||||
it("keeps the safe cap under the 16 MiB frame limit with headroom", () => {
|
||||
expect(SAFE_PAYLOAD_BYTES).toBeLessThan(FRAME_LIMIT_BYTES_FOR_TEST);
|
||||
expect(FRAME_LIMIT_BYTES_FOR_TEST - SAFE_PAYLOAD_BYTES).toBeGreaterThanOrEqual(
|
||||
1024 * 1024,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes payloads at or under the cap", () => {
|
||||
expect(checkPayloadFrameSize({ ok: true }, "hint")).toBeNull();
|
||||
// A string just under the cap (account for JSON quotes).
|
||||
const almost = "x".repeat(SAFE_PAYLOAD_BYTES - 2);
|
||||
expect(payloadByteLength(almost)).toBeLessThanOrEqual(SAFE_PAYLOAD_BYTES);
|
||||
expect(checkPayloadFrameSize(almost, "hint")).toBeNull();
|
||||
});
|
||||
|
||||
it("flags payloads over the cap with byte count and hint", () => {
|
||||
const big = "x".repeat(SAFE_PAYLOAD_BYTES + 1024);
|
||||
const res = checkPayloadFrameSize(big, "narrow the range");
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.oversized).toBe(true);
|
||||
expect(res!.success).toBe(false);
|
||||
expect(res!.bytes).toBeGreaterThan(SAFE_PAYLOAD_BYTES);
|
||||
expect(res!.limitBytes).toBe(SAFE_PAYLOAD_BYTES);
|
||||
expect(res!.error).toContain("narrow the range");
|
||||
expect(res!.error).toMatch(/MiB/);
|
||||
});
|
||||
|
||||
it("reports the size in MiB", () => {
|
||||
const err = oversizedPayloadError(20 * 1024 * 1024, "do X");
|
||||
expect(err.error).toContain("20.0 MiB");
|
||||
});
|
||||
});
|
||||
|
||||
function mockKV(store = new Map<string, Map<string, unknown>>()) {
|
||||
return {
|
||||
get: async () => null,
|
||||
set: async <T>(s: string, k: string, d: T) => {
|
||||
if (!store.has(s)) store.set(s, new Map());
|
||||
store.get(s)!.set(k, d);
|
||||
return d;
|
||||
},
|
||||
delete: async () => {},
|
||||
update: async () => {},
|
||||
list: async <T>(scope: string): Promise<T[]> =>
|
||||
Array.from(store.get(scope)?.values() ?? []) as T[],
|
||||
_store: store,
|
||||
};
|
||||
}
|
||||
|
||||
function mockSdk(kv: ReturnType<typeof mockKV>) {
|
||||
const fns = new Map<string, Function>();
|
||||
return {
|
||||
registerFunction: (id: string, h: Function) => fns.set(id, h),
|
||||
registerTrigger: () => {},
|
||||
trigger: async (input: { function_id: string; payload?: unknown }) =>
|
||||
fns.get(input.function_id)?.(input.payload),
|
||||
_fns: fns,
|
||||
_kv: kv,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("mem::export frame guard", () => {
|
||||
it("returns the export object when it fits under the frame limit", async () => {
|
||||
const kv = mockKV();
|
||||
await kv.set(KV.sessions, "s1", {
|
||||
id: "s1",
|
||||
project: "p",
|
||||
cwd: "/p",
|
||||
startedAt: "2026-08-01T00:00:00Z",
|
||||
status: "completed",
|
||||
observationCount: 0,
|
||||
} as Session);
|
||||
const sdk = mockSdk(kv);
|
||||
registerExportImportFunction(sdk, kv as never);
|
||||
const result = (await (sdk as any).trigger({
|
||||
function_id: "mem::export",
|
||||
payload: {},
|
||||
})) as { version?: string; oversized?: boolean };
|
||||
expect(result.oversized).toBeUndefined();
|
||||
expect(result.version).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns a clean oversized error (not the object) when the export exceeds the cap", async () => {
|
||||
const kv = mockKV();
|
||||
// One memory whose content alone pushes the serialized export past the cap.
|
||||
const huge = "z".repeat(SAFE_PAYLOAD_BYTES + 4096);
|
||||
await kv.set(KV.memories, "m1", {
|
||||
id: "m1",
|
||||
type: "pattern",
|
||||
title: "big",
|
||||
content: huge,
|
||||
createdAt: "2026-08-01T00:00:00Z",
|
||||
updatedAt: "2026-08-01T00:00:00Z",
|
||||
concepts: [],
|
||||
files: [],
|
||||
sessionIds: [],
|
||||
strength: 5,
|
||||
version: 1,
|
||||
isLatest: true,
|
||||
});
|
||||
const sdk = mockSdk(kv);
|
||||
registerExportImportFunction(sdk, kv as never);
|
||||
const result = (await (sdk as any).trigger({
|
||||
function_id: "mem::export",
|
||||
payload: {},
|
||||
})) as { oversized?: boolean; success?: boolean; bytes?: number; version?: string };
|
||||
|
||||
// The giant object is never returned; a small error object is.
|
||||
expect(result.oversized).toBe(true);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.bytes).toBeGreaterThan(SAFE_PAYLOAD_BYTES);
|
||||
expect(result.version).toBeUndefined();
|
||||
// The error object itself is tiny (would never blow the frame).
|
||||
expect(payloadByteLength(result)).toBeLessThan(2048);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/logger.js", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
import { registerApiTriggers } from "../src/triggers/api.js";
|
||||
import { KV } from "../src/state/schema.js";
|
||||
import { SAFE_PAYLOAD_BYTES } from "../src/state/frame-guard.js";
|
||||
import type { Memory } from "../src/types.js";
|
||||
|
||||
// A project-scoped mesh export must filter memories like actions: unscoped
|
||||
// memories leak across projects and can push the payload past the transport
|
||||
// frame limit even when the requested project's own slice fits.
|
||||
|
||||
const SECRET = "mesh-test-secret";
|
||||
|
||||
function mockKV(store = new Map<string, Map<string, unknown>>()) {
|
||||
return {
|
||||
get: async () => null,
|
||||
set: async <T>(s: string, k: string, d: T) => {
|
||||
if (!store.has(s)) store.set(s, new Map());
|
||||
store.get(s)!.set(k, d);
|
||||
return d;
|
||||
},
|
||||
delete: async () => {},
|
||||
update: async () => {},
|
||||
list: async <T>(scope: string): Promise<T[]> =>
|
||||
Array.from(store.get(scope)?.values() ?? []) as T[],
|
||||
_store: store,
|
||||
};
|
||||
}
|
||||
|
||||
function mockSdk() {
|
||||
const fns = new Map<string, Function>();
|
||||
return {
|
||||
registerFunction: (id: string, h: Function) => fns.set(id, h),
|
||||
registerTrigger: () => {},
|
||||
trigger: async (input: { function_id: string; payload?: unknown }) =>
|
||||
fns.get(input.function_id)?.(input.payload),
|
||||
_fns: fns,
|
||||
};
|
||||
}
|
||||
|
||||
function memory(id: string, project: string, content = "x"): Memory {
|
||||
return {
|
||||
id,
|
||||
type: "pattern",
|
||||
title: id,
|
||||
content,
|
||||
createdAt: "2026-08-01T00:00:00Z",
|
||||
updatedAt: "2026-08-01T00:00:00Z",
|
||||
concepts: [],
|
||||
files: [],
|
||||
sessionIds: [],
|
||||
strength: 5,
|
||||
version: 1,
|
||||
isLatest: true,
|
||||
project,
|
||||
};
|
||||
}
|
||||
|
||||
async function meshExport(
|
||||
sdk: ReturnType<typeof mockSdk>,
|
||||
project?: string,
|
||||
): Promise<{ status_code: number; body: Record<string, unknown> }> {
|
||||
const handler = sdk._fns.get("api::mesh-export")!;
|
||||
return handler({
|
||||
headers: { authorization: `Bearer ${SECRET}` },
|
||||
query_params: project ? { project } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("api::mesh-export project scoping", () => {
|
||||
it("excludes other projects' memories from a project-scoped export", async () => {
|
||||
const kv = mockKV();
|
||||
await kv.set(KV.memories, "m-alpha", memory("m-alpha", "alpha"));
|
||||
await kv.set(KV.memories, "m-beta", memory("m-beta", "beta"));
|
||||
const sdk = mockSdk();
|
||||
registerApiTriggers(sdk as never, kv as never, SECRET);
|
||||
|
||||
const res = await meshExport(sdk, "alpha");
|
||||
|
||||
expect(res.status_code).toBe(200);
|
||||
const memories = res.body.memories as Memory[];
|
||||
expect(memories.map((m) => m.id)).toEqual(["m-alpha"]);
|
||||
expect(memories.some((m) => m.project === "beta")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns all memories when no project is provided", async () => {
|
||||
const kv = mockKV();
|
||||
await kv.set(KV.memories, "m-alpha", memory("m-alpha", "alpha"));
|
||||
await kv.set(KV.memories, "m-beta", memory("m-beta", "beta"));
|
||||
const sdk = mockSdk();
|
||||
registerApiTriggers(sdk as never, kv as never, SECRET);
|
||||
|
||||
const res = await meshExport(sdk);
|
||||
|
||||
expect(res.status_code).toBe(200);
|
||||
const memories = res.body.memories as Memory[];
|
||||
expect(memories.map((m) => m.id).sort()).toEqual(["m-alpha", "m-beta"]);
|
||||
});
|
||||
|
||||
it("avoids the 413 when only another project's memory is oversized", async () => {
|
||||
const kv = mockKV();
|
||||
// A single beta memory alone blows the frame; alpha's slice is tiny.
|
||||
await kv.set(
|
||||
KV.memories,
|
||||
"m-beta-huge",
|
||||
memory("m-beta-huge", "beta", "z".repeat(SAFE_PAYLOAD_BYTES + 4096)),
|
||||
);
|
||||
await kv.set(KV.memories, "m-alpha", memory("m-alpha", "alpha"));
|
||||
const sdk = mockSdk();
|
||||
registerApiTriggers(sdk as never, kv as never, SECRET);
|
||||
|
||||
// Scoped to alpha: the huge beta memory is filtered out before the frame
|
||||
// guard runs, so the request succeeds instead of 413-ing.
|
||||
const scoped = await meshExport(sdk, "alpha");
|
||||
expect(scoped.status_code).toBe(200);
|
||||
expect((scoped.body.memories as Memory[]).map((m) => m.id)).toEqual([
|
||||
"m-alpha",
|
||||
]);
|
||||
|
||||
// Unscoped: the oversized memory is included, so the guard fires (413).
|
||||
const unscoped = await meshExport(sdk);
|
||||
expect(unscoped.status_code).toBe(413);
|
||||
expect((unscoped.body as { oversized?: boolean }).oversized).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,9 @@ describe("OpenCode plugin project name resolution", () => {
|
||||
else process.env.AGENTMEMORY_PROJECT_NAME = savedProjectName;
|
||||
});
|
||||
|
||||
async function projectFor(ctx: Record<string, unknown>): Promise<unknown> {
|
||||
async function startPayloadFor(
|
||||
ctx: Record<string, unknown>,
|
||||
): Promise<{ project: unknown; cwd: unknown }> {
|
||||
const { AgentmemoryCapturePlugin } = await import(
|
||||
"../plugin/opencode/agentmemory-capture.ts"
|
||||
);
|
||||
@@ -67,7 +69,12 @@ describe("OpenCode plugin project name resolution", () => {
|
||||
(c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("/session/start"),
|
||||
);
|
||||
if (!startCall) throw new Error("no /session/start call captured");
|
||||
return JSON.parse((startCall[1] as { body: string }).body).project;
|
||||
const body = JSON.parse((startCall[1] as { body: string }).body);
|
||||
return { project: body.project, cwd: body.cwd };
|
||||
}
|
||||
|
||||
async function projectFor(ctx: Record<string, unknown>): Promise<unknown> {
|
||||
return (await startPayloadFor(ctx)).project;
|
||||
}
|
||||
|
||||
it("uses trimmed AGENTMEMORY_PROJECT_NAME when set", async () => {
|
||||
@@ -75,20 +82,50 @@ describe("OpenCode plugin project name resolution", () => {
|
||||
expect(await projectFor({ worktree: "/should/be/ignored" })).toBe("my-proj");
|
||||
});
|
||||
|
||||
it("treats whitespace-only env value as unset and falls back", async () => {
|
||||
it("treats whitespace-only env value as unset and falls back to the basename", async () => {
|
||||
process.env.AGENTMEMORY_PROJECT_NAME = " ";
|
||||
expect(await projectFor({ worktree: "/repo/alpha" })).toBe("/repo/alpha");
|
||||
expect(await projectFor({ worktree: "/repo/alpha" })).toBe("alpha");
|
||||
});
|
||||
|
||||
it("falls back to ctx.worktree when env is unset", async () => {
|
||||
expect(await projectFor({ worktree: "/repo/alpha" })).toBe("/repo/alpha");
|
||||
// Canonicalization: project is the git-toplevel/cwd BASENAME (matching the
|
||||
// hooks' resolveProject), while cwd keeps the full path. A nonexistent dir
|
||||
// cannot be a git repo, so these exercise the basename fallback.
|
||||
it("sends the basename as project and the full path as cwd", async () => {
|
||||
const payload = await startPayloadFor({ worktree: "/repo/alpha" });
|
||||
expect(payload.project).toBe("alpha");
|
||||
expect(payload.cwd).toBe("/repo/alpha");
|
||||
});
|
||||
|
||||
it("falls back to ctx.project.id when worktree is absent", async () => {
|
||||
expect(await projectFor({ project: { id: "/repo/beta" } })).toBe("/repo/beta");
|
||||
expect(await projectFor({ project: { id: "/repo/beta" } })).toBe("beta");
|
||||
});
|
||||
|
||||
it("falls back to process.cwd() when no ctx field is present", async () => {
|
||||
expect(await projectFor({})).toBe(process.cwd());
|
||||
it("resolves the git toplevel basename inside a real repository", async () => {
|
||||
const { mkdtempSync, mkdirSync, rmSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
const root = mkdtempSync(join(tmpdir(), "amem-oc-"));
|
||||
const repo = join(root, "oc-fixture-repo");
|
||||
const nested = join(repo, "src", "deep");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: repo, stdio: "ignore" });
|
||||
try {
|
||||
// Subdirectory of the repo still resolves to the repo basename.
|
||||
expect(await projectFor({ worktree: nested })).toBe("oc-fixture-repo");
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode plugin file-tool matching", () => {
|
||||
const plugin = readFileSync("plugin/opencode/agentmemory-capture.ts", "utf-8");
|
||||
|
||||
it("matches OpenCode's lowercase tool names case-insensitively", () => {
|
||||
// OpenCode reports "read"/"edit"/... in lowercase; the old capitalized
|
||||
// set never matched, silently disabling file enrichment.
|
||||
expect(plugin).toContain('FILE_TOOLS = new Set(["read", "write", "edit", "glob", "grep"])');
|
||||
expect(plugin).toContain('FILE_TOOLS.has(String(input.tool ?? "").toLowerCase())');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
// @ts-expect-error plain .mjs module without type declarations
|
||||
import { FilesystemWatcher } from "../integrations/filesystem-watcher/watcher.mjs";
|
||||
import { parseJsonlText } from "../src/replay/jsonl-parser.js";
|
||||
// @ts-expect-error plain .mjs module without type declarations
|
||||
import { configFromEnv } from "../integrations/filesystem-watcher/watcher.mjs";
|
||||
|
||||
// Project-scope parity: every capture surface must resolve `project` the same
|
||||
// way the hooks do (env override, git toplevel basename, cwd basename), or the
|
||||
// same repo fragments into per-agent memory buckets that never cross-recall.
|
||||
|
||||
function transcriptLine(cwd: string): string {
|
||||
return JSON.stringify({
|
||||
type: "user",
|
||||
uuid: "u1",
|
||||
sessionId: "sess-parity",
|
||||
timestamp: "2026-08-01T10:00:00.000Z",
|
||||
cwd,
|
||||
message: { role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("replay deriveProject (via parseJsonlText)", () => {
|
||||
it("uses the basename of a posix cwd", () => {
|
||||
const parsed = parseJsonlText(transcriptLine("/home/dev/myrepo"));
|
||||
expect(parsed.project).toBe("myrepo");
|
||||
});
|
||||
|
||||
it("uses the basename of a Windows cwd instead of the whole raw path", () => {
|
||||
const parsed = parseJsonlText(transcriptLine("C:\\Users\\dev\\myrepo"));
|
||||
expect(parsed.project).toBe("myrepo");
|
||||
});
|
||||
|
||||
it("handles mixed separators", () => {
|
||||
const parsed = parseJsonlText(transcriptLine("C:\\Users\\dev/myrepo"));
|
||||
expect(parsed.project).toBe("myrepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("git-toplevel resolution parity", () => {
|
||||
let tmpRoot: string;
|
||||
let repoDir: string;
|
||||
let nestedDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "amem-parity-"));
|
||||
repoDir = join(tmpRoot, "parity-fixture-repo");
|
||||
nestedDir = join(repoDir, "packages", "core");
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: repoDir, stdio: "ignore" });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("replay resolves a locally-present subdirectory cwd to the repo basename", () => {
|
||||
const parsed = parseJsonlText(transcriptLine(nestedDir));
|
||||
expect(parsed.project).toBe("parity-fixture-repo");
|
||||
});
|
||||
|
||||
it("replay falls back to the basename for a cwd that no longer exists", () => {
|
||||
const parsed = parseJsonlText(transcriptLine(join(tmpRoot, "gone", "old-checkout")));
|
||||
expect(parsed.project).toBe("old-checkout");
|
||||
});
|
||||
|
||||
it("watcher derives the repo basename when watching a subdirectory", () => {
|
||||
const w = new FilesystemWatcher({
|
||||
roots: [nestedDir],
|
||||
baseUrl: "http://localhost:3111",
|
||||
logger: {},
|
||||
});
|
||||
expect(w.project).toBe("parity-fixture-repo");
|
||||
});
|
||||
|
||||
it("multi-root watcher stamps each event with its own root's project", async () => {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
const repoB = join(tmpRoot, "second-fixture-repo");
|
||||
mkdirSync(repoB, { recursive: true });
|
||||
execFileSync("git", ["init", "--quiet"], { cwd: repoB, stdio: "ignore" });
|
||||
writeFileSync(join(repoDir, "a.txt"), "alpha", "utf8");
|
||||
writeFileSync(join(repoB, "b.txt"), "beta", "utf8");
|
||||
|
||||
const calls: Array<{ project: unknown; cwd: unknown }> = [];
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (_url: unknown, init?: { body?: string }) => {
|
||||
const body = JSON.parse(init?.body ?? "{}");
|
||||
calls.push({ project: body.project, cwd: body.cwd });
|
||||
return { ok: true, json: async () => ({}) } as Response;
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const w = new FilesystemWatcher({
|
||||
roots: [repoDir, repoB],
|
||||
baseUrl: "http://localhost:3111",
|
||||
logger: {},
|
||||
});
|
||||
await w.flush(w.roots[0], "a.txt");
|
||||
await w.flush(w.roots[1], "b.txt");
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0].project).toBe("parity-fixture-repo");
|
||||
expect(calls[1].project).toBe("second-fixture-repo");
|
||||
});
|
||||
|
||||
it("watcher falls back to the root basename outside a repository", () => {
|
||||
const plain = join(tmpRoot, "plain-dir");
|
||||
mkdirSync(plain, { recursive: true });
|
||||
const w = new FilesystemWatcher({
|
||||
roots: [plain],
|
||||
baseUrl: "http://localhost:3111",
|
||||
logger: {},
|
||||
});
|
||||
expect(w.project).toBe("plain-dir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fs-watcher configFromEnv project override", () => {
|
||||
it("prefers the canonical AGENTMEMORY_PROJECT_NAME", () => {
|
||||
const cfg = configFromEnv({
|
||||
AGENTMEMORY_FS_WATCH: "/tmp",
|
||||
AGENTMEMORY_PROJECT_NAME: "canonical-name",
|
||||
AGENTMEMORY_PROJECT: "legacy-name",
|
||||
});
|
||||
expect(cfg.project).toBe("canonical-name");
|
||||
});
|
||||
|
||||
it("falls back to the deprecated AGENTMEMORY_PROJECT alias", () => {
|
||||
const cfg = configFromEnv({
|
||||
AGENTMEMORY_FS_WATCH: "/tmp",
|
||||
AGENTMEMORY_PROJECT: "legacy-name",
|
||||
});
|
||||
expect(cfg.project).toBe("legacy-name");
|
||||
});
|
||||
|
||||
it("is null when neither is set (watcher derives from the root basename)", () => {
|
||||
const cfg = configFromEnv({ AGENTMEMORY_FS_WATCH: "/tmp" });
|
||||
expect(cfg.project).toBeNull();
|
||||
});
|
||||
|
||||
it("trims values and treats whitespace-only as unset, like resolveProject", () => {
|
||||
expect(
|
||||
configFromEnv({
|
||||
AGENTMEMORY_FS_WATCH: "/tmp",
|
||||
AGENTMEMORY_PROJECT_NAME: " padded ",
|
||||
}).project,
|
||||
).toBe("padded");
|
||||
expect(
|
||||
configFromEnv({
|
||||
AGENTMEMORY_FS_WATCH: "/tmp",
|
||||
AGENTMEMORY_PROJECT_NAME: " ",
|
||||
AGENTMEMORY_PROJECT: "legacy-name",
|
||||
}).project,
|
||||
).toBe("legacy-name");
|
||||
expect(
|
||||
configFromEnv({
|
||||
AGENTMEMORY_FS_WATCH: "/tmp",
|
||||
AGENTMEMORY_PROJECT_NAME: " ",
|
||||
AGENTMEMORY_PROJECT: " ",
|
||||
}).project,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "0.9.28",
|
||||
"mcpTools": 53,
|
||||
"version": "0.9.29",
|
||||
"mcpTools": 54,
|
||||
"hooks": 12,
|
||||
"restEndpoints": 128,
|
||||
"testsPassing": 1428,
|
||||
"generatedAt": "2026-07-19T10:24:26.108Z"
|
||||
"restEndpoints": 130,
|
||||
"testsPassing": 1610,
|
||||
"generatedAt": "2026-08-09T09:15:51.690Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user