From 9c82d2aa7dad496d3c41ec189e3762c4063483ac Mon Sep 17 00:00:00 2001
From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com>
Date: Sun, 9 Aug 2026 13:22:25 +0100
Subject: [PATCH 1/8] 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
---
.github/workflows/ci.yml | 12 +-
AGENTS.md | 8 +-
CHANGELOG.md | 46 +++++
CONTRIBUTING.md | 23 +--
README.md | 8 +-
assets/tags/light/stat-tests.svg | 4 +-
assets/tags/stat-tests.svg | 4 +-
deploy/coolify/Dockerfile | 2 +-
deploy/coolify/docker-compose.yml | 2 +-
deploy/fly/Dockerfile | 2 +-
deploy/railway/Dockerfile | 2 +-
deploy/render/Dockerfile | 2 +-
deploy/render/render.yaml | 2 +-
integrations/filesystem-watcher/watcher.mjs | 36 +++-
integrations/hermes/__init__.py | 31 +++-
integrations/pi/index.ts | 35 +++-
package.json | 2 +-
packages/mcp/package.json | 2 +-
plugin/.claude-plugin/plugin.json | 2 +-
plugin/.codex-plugin/plugin.json | 2 +-
plugin/opencode/agentmemory-capture.ts | 48 +++--
plugin/opencode/plugin.json | 2 +-
plugin/plugin.json | 2 +-
plugin/scripts/antigravity-bridge.mjs | 0
.../skills/agentmemory-rest-api/REFERENCE.md | 13 +-
scripts/skills/generate.ts | 10 +-
src/cli.ts | 33 +++-
src/functions/export-import.ts | 16 +-
src/index.ts | 2 +-
src/replay/jsonl-parser.ts | 37 +++-
src/state/frame-guard.ts | 45 +++++
src/triggers/api.ts | 12 +-
src/types.ts | 2 +-
src/version.ts | 2 +-
test/cli-second-instance-guard.test.ts | 29 +++
test/consistency.test.ts | 9 +
test/export-import.test.ts | 3 +-
test/frame-guard.test.ts | 137 ++++++++++++++
test/mesh-export-project-scope.test.ts | 129 +++++++++++++
test/opencode-auto-context.test.ts | 55 +++++-
test/project-scope-parity.test.ts | 169 ++++++++++++++++++
website/lib/generated-meta.json | 10 +-
42 files changed, 905 insertions(+), 87 deletions(-)
mode change 100644 => 100755 plugin/scripts/antigravity-bridge.mjs
create mode 100644 src/state/frame-guard.ts
create mode 100644 test/cli-second-instance-guard.test.ts
create mode 100644 test/frame-guard.test.ts
create mode 100644 test/mesh-export-project-scope.test.ts
create mode 100644 test/project-scope-parity.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b60a7874..cbedfaa6d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 6f64946fc..46dc67859 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24a1722e8..e877b7fca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 ` 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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 39d38d6fc..d865ecb9e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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
diff --git a/README.md b/README.md
index eb6bd1fa6..a716d8f1c 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@
-
+
@@ -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 ` |
-**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`:
-129 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` 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 ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers.
Key endpoints
@@ -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)
```
diff --git a/assets/tags/light/stat-tests.svg b/assets/tags/light/stat-tests.svg
index a309d2c74..b8f386db0 100644
--- a/assets/tags/light/stat-tests.svg
+++ b/assets/tags/light/stat-tests.svg
@@ -1,5 +1,5 @@
-
+
- 1428+
+ 1596+
TESTS PASSING
diff --git a/assets/tags/stat-tests.svg b/assets/tags/stat-tests.svg
index 4b2dfe07c..8a4637dde 100644
--- a/assets/tags/stat-tests.svg
+++ b/assets/tags/stat-tests.svg
@@ -1,5 +1,5 @@
-
+
- 1428+
+ 1596+
TESTS PASSING
diff --git a/deploy/coolify/Dockerfile b/deploy/coolify/Dockerfile
index e95bd70ec..c0a6bb6c9 100644
--- a/deploy/coolify/Dockerfile
+++ b/deploy/coolify/Dockerfile
@@ -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
diff --git a/deploy/coolify/docker-compose.yml b/deploy/coolify/docker-compose.yml
index c2f93ab9b..b34823dbc 100644
--- a/deploy/coolify/docker-compose.yml
+++ b/deploy/coolify/docker-compose.yml
@@ -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
diff --git a/deploy/fly/Dockerfile b/deploy/fly/Dockerfile
index 51da03a47..e09469988 100644
--- a/deploy/fly/Dockerfile
+++ b/deploy/fly/Dockerfile
@@ -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
diff --git a/deploy/railway/Dockerfile b/deploy/railway/Dockerfile
index 51da03a47..e09469988 100644
--- a/deploy/railway/Dockerfile
+++ b/deploy/railway/Dockerfile
@@ -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
diff --git a/deploy/render/Dockerfile b/deploy/render/Dockerfile
index 51da03a47..e09469988 100644
--- a/deploy/render/Dockerfile
+++ b/deploy/render/Dockerfile
@@ -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
diff --git a/deploy/render/render.yaml b/deploy/render/render.yaml
index b2333805a..d1871d579 100644
--- a/deploy/render/render.yaml
+++ b/deploy/render/render.yaml
@@ -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
diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs
index a73d178f3..27fb4f022 100644
--- a/integrations/filesystem-watcher/watcher.mjs
+++ b/integrations/filesystem-watcher/watcher.mjs
@@ -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",
diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py
index 2933632d0..79ab21889 100644
--- a/integrations/hermes/__init__.py
+++ b/integrations/hermes/__init__.py
@@ -13,6 +13,30 @@
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 @@ def is_available(self) -> bool:
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 @@ def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None:
"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",
diff --git a/integrations/pi/index.ts b/integrations/pi/index.ts
index 9c6cfc702..e6ad648de 100644
--- a/integrations/pi/index.ts
+++ b/integrations/pi/index.ts
@@ -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();
+ 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",
diff --git a/package.json b/package.json
index 77185ad5f..79b716c92 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index bdc312034..c88f9c89e 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -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": {
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index 27bdc81eb..52fc79280 100644
--- a/plugin/.claude-plugin/plugin.json
+++ b/plugin/.claude-plugin/plugin.json
@@ -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",
diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json
index ad262621d..cbcd5731a 100644
--- a/plugin/.codex-plugin/plugin.json
+++ b/plugin/.codex-plugin/plugin.json
@@ -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",
diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts
index 46419ef8c..1a1d04268 100644
--- a/plugin/opencode/agentmemory-capture.ts
+++ b/plugin/opencode/agentmemory-capture.ts
@@ -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 | 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>();
const seenSubtaskIds = new Map>();
const seenToolCallIds = new Map>();
@@ -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 | 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) {
diff --git a/plugin/opencode/plugin.json b/plugin/opencode/plugin.json
index 1472752e9..cf06cee5a 100644
--- a/plugin/opencode/plugin.json
+++ b/plugin/opencode/plugin.json
@@ -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",
diff --git a/plugin/plugin.json b/plugin/plugin.json
index 90d248c58..ad8025aec 100644
--- a/plugin/plugin.json
+++ b/plugin/plugin.json
@@ -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",
diff --git a/plugin/scripts/antigravity-bridge.mjs b/plugin/scripts/antigravity-bridge.mjs
old mode 100644
new mode 100755
diff --git a/plugin/skills/agentmemory-rest-api/REFERENCE.md b/plugin/skills/agentmemory-rest-api/REFERENCE.md
index b92e35a9e..a176863c5 100644
--- a/plugin/skills/agentmemory-rest-api/REFERENCE.md
+++ b/plugin/skills/agentmemory-rest-api/REFERENCE.md
@@ -5,10 +5,11 @@ Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run `
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` |
diff --git a/scripts/skills/generate.ts b/scripts/skills/generate.ts
index 44ccf941a..33e14bf4a 100644
--- a/scripts/skills/generate.ts
+++ b/scripts/skills/generate.ts
@@ -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();
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.`,
"",
diff --git a/src/cli.ts b/src/cli.ts
index 2ae20f08b..918e011cd 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -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 Promise> = {
"import-jsonl": runImportJsonl,
};
-const handler = commands[args[0] ?? ""] ?? main;
+const first = args[0] ?? "";
+async function unknownCommand(): Promise {
+ 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);
diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts
index 2e30e070f..23854a97f 100644
--- a/src/functions/export-import.ts
+++ b/src/functions/export-import.ts
@@ -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,
diff --git a/src/index.ts b/src/index.ts
index 5f66d76c9..198a6dc3d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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`,
diff --git a/src/replay/jsonl-parser.ts b/src/replay/jsonl-parser.ts
index 5060c3451..ec2f33d1d 100644
--- a/src/replay/jsonl-parser.ts
+++ b/src/replay/jsonl-parser.ts
@@ -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();
+
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;
diff --git a/src/state/frame-guard.ts b/src/state/frame-guard.ts
new file mode 100644
index 000000000..8651b384d
--- /dev/null
+++ b/src/state/frame-guard.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;
diff --git a/src/triggers/api.ts b/src/triggers/api.ts
index 701e87374..7560e873d 100644
--- a/src/triggers/api.ts
+++ b/src/triggers/api.ts
@@ -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 = (items: T[], field: "updatedAt" | "createdAt") =>
items.filter((i) => new Date((i as Record)[field] as string).getTime() > sinceTime);
- const memories = await kv.list(KV.memories);
+ let memories = await kv.list(KV.memories);
let actions = await kv.list(KV.actions);
if (project) {
+ memories = memories.filter((m) => m.project === project);
actions = actions.filter((a) => a.project === project);
}
const body: Record = {
@@ -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 };
},
);
diff --git a/src/types.ts b/src/types.ts
index 7cda80ffb..2f3f0285f 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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;
diff --git a/src/version.ts b/src/version.ts
index 6d09f4f4a..84d83bdcb 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const VERSION = "0.9.28";
+export const VERSION = "0.9.29";
diff --git a/test/cli-second-instance-guard.test.ts b/test/cli-second-instance-guard.test.ts
new file mode 100644
index 000000000..176734157
--- /dev/null
+++ b/test/cli-second-instance-guard.test.ts
@@ -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");
+ });
+});
diff --git a/test/consistency.test.ts b/test/consistency.test.ts
index e0871cbf8..9e2cc53fa 100644
--- a/test/consistency.test.ts
+++ b/test/consistency.test.ts
@@ -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}"`);
diff --git a/test/export-import.test.ts b/test/export-import.test.ts
index a345ca3d5..d33aacb1e 100644
--- a/test/export-import.test.ts
+++ b/test/export-import.test.ts
@@ -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");
diff --git a/test/frame-guard.test.ts b/test/frame-guard.test.ts
new file mode 100644
index 000000000..fbb73461c
--- /dev/null
+++ b/test/frame-guard.test.ts
@@ -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>()) {
+ return {
+ get: async () => null,
+ set: async (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 (scope: string): Promise =>
+ Array.from(store.get(scope)?.values() ?? []) as T[],
+ _store: store,
+ };
+}
+
+function mockSdk(kv: ReturnType) {
+ const fns = new Map();
+ 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);
+ });
+});
diff --git a/test/mesh-export-project-scope.test.ts b/test/mesh-export-project-scope.test.ts
new file mode 100644
index 000000000..7ff72c7e1
--- /dev/null
+++ b/test/mesh-export-project-scope.test.ts
@@ -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>()) {
+ return {
+ get: async () => null,
+ set: async (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 (scope: string): Promise =>
+ Array.from(store.get(scope)?.values() ?? []) as T[],
+ _store: store,
+ };
+}
+
+function mockSdk() {
+ const fns = new Map();
+ 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,
+ project?: string,
+): Promise<{ status_code: number; body: Record }> {
+ 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);
+ });
+});
diff --git a/test/opencode-auto-context.test.ts b/test/opencode-auto-context.test.ts
index 2691e0965..dd59b0411 100644
--- a/test/opencode-auto-context.test.ts
+++ b/test/opencode-auto-context.test.ts
@@ -53,7 +53,9 @@ describe("OpenCode plugin project name resolution", () => {
else process.env.AGENTMEMORY_PROJECT_NAME = savedProjectName;
});
- async function projectFor(ctx: Record): Promise {
+ async function startPayloadFor(
+ ctx: Record,
+ ): 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): Promise {
+ 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())');
});
});
diff --git a/test/project-scope-parity.test.ts b/test/project-scope-parity.test.ts
new file mode 100644
index 000000000..408e4531b
--- /dev/null
+++ b/test/project-scope-parity.test.ts
@@ -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();
+ });
+});
diff --git a/website/lib/generated-meta.json b/website/lib/generated-meta.json
index 06792f8e5..8ebf1fd05 100644
--- a/website/lib/generated-meta.json
+++ b/website/lib/generated-meta.json
@@ -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"
}
From 2973e4ec4c40d323a08daa34220118010e73a2c3 Mon Sep 17 00:00:00 2001
From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com>
Date: Sun, 9 Aug 2026 13:30:54 +0100
Subject: [PATCH 2/8] chore: remove drafts (#1170)
The six drafts covered vulnerabilities fixed in 0.8.2 and were staging
material for filing through the repository Security tab. GitHub reads
advisories only from the Security tab, so the folder carries no function
in the repo.
---
.github/security-advisories/01-viewer-xss.md | 46 --------------
.github/security-advisories/02-curl-sh-rce.md | 57 -----------------
.../03-default-bind-0000.md | 62 -------------------
.github/security-advisories/04-mesh-unauth.md | 47 --------------
.../05-obsidian-export-traversal.md | 61 ------------------
.../06-privacy-redaction-incomplete.md | 60 ------------------
6 files changed, 333 deletions(-)
delete mode 100644 .github/security-advisories/01-viewer-xss.md
delete mode 100644 .github/security-advisories/02-curl-sh-rce.md
delete mode 100644 .github/security-advisories/03-default-bind-0000.md
delete mode 100644 .github/security-advisories/04-mesh-unauth.md
delete mode 100644 .github/security-advisories/05-obsidian-export-traversal.md
delete mode 100644 .github/security-advisories/06-privacy-redaction-incomplete.md
diff --git a/.github/security-advisories/01-viewer-xss.md b/.github/security-advisories/01-viewer-xss.md
deleted file mode 100644
index 046c28627..000000000
--- a/.github/security-advisories/01-viewer-xss.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# GHSA Draft: Stored XSS in agentmemory real-time viewer
-
-**Severity:** Critical · **CVSS 3.1:** 9.6 (`AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:L`)
-**CWE:** [CWE-79 — Improper Neutralization of Input During Web Page Generation](https://cwe.mitre.org/data/definitions/79.html)
-**Affected versions:** `< 0.8.2`
-**Patched version:** `0.8.2`
-
-## Summary
-
-agentmemory's real-time viewer (default port 3113) rendered user-controlled data — tool outputs, file paths, memory titles, observation content — into HTML using inline `onclick=` event handlers. The viewer's Content Security Policy simultaneously allowed `script-src 'unsafe-inline'`, meaning injected JavaScript would execute in the reader's browser context.
-
-## Impact
-
-Any data captured by agentmemory hooks — which includes tool output from Claude Code, Cursor, or any other agent — becomes an XSS vector when the user opens the viewer. An attacker with the ability to influence any captured observation (e.g., by sending a crafted file contents to be read by an agent, or by planting a malicious commit message in a repository) could:
-
-- Exfiltrate the entire memory store via authenticated requests from the browser
-- Read `AGENTMEMORY_SECRET` if the viewer was configured with auth
-- Make requests to arbitrary endpoints on behalf of the viewer user
-- Modify the DOM to mislead the developer
-- Pivot to other localhost services on the developer's machine
-
-The viewer runs on localhost by default but is **reachable from the browser**, so standard same-origin protections don't help.
-
-## Patches
-
-Fixed in **0.8.2**:
-
-- All inline `on*=` handlers removed from `src/viewer/index.html`
-- Replaced with delegated `data-action` event handling
-- CSP switched to a **per-response script nonce** (`script-src 'nonce-'`)
-- Added `script-src-attr 'none'` to block any inline handler attributes even if injected
-- Viewer HTML now rendered through `src/viewer/document.ts` which generates a fresh nonce per request
-
-## Workarounds
-
-**None.** Users on affected versions should upgrade to 0.8.2 immediately. Do not open `http://localhost:3113` in a browser on affected versions if you suspect any of your captured observations may contain attacker-controlled content.
-
-## References
-
-- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108)
-- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f)
-- Reporter: @eng-pf
-
-## Credit
-
-@eng-pf submitted PR #108 with fixes for this and 5 other vulnerabilities.
diff --git a/.github/security-advisories/02-curl-sh-rce.md b/.github/security-advisories/02-curl-sh-rce.md
deleted file mode 100644
index f32a3e817..000000000
--- a/.github/security-advisories/02-curl-sh-rce.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# GHSA Draft: Remote shell script execution in agentmemory CLI startup
-
-**Severity:** Critical · **CVSS 3.1:** 9.8 (`AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H`)
-**CWE:** [CWE-494 — Download of Code Without Integrity Check](https://cwe.mitre.org/data/definitions/494.html), [CWE-829 — Inclusion of Functionality from Untrusted Control Sphere](https://cwe.mitre.org/data/definitions/829.html)
-**Affected versions:** `< 0.8.2`
-**Patched version:** `0.8.2`
-
-## Summary
-
-The agentmemory CLI (`npx @agentmemory/agentmemory`) auto-installed the iii-engine binary by piping a remote shell script into `sh`:
-
-```ts
-execSync("curl -fsSL https://install.iii.dev/iii/main/install.sh | sh")
-```
-
-This happened automatically on first run if `iii` was not found in `$PATH`. The script was fetched over HTTPS and executed with the permissions of the user running `npx agentmemory`. No checksum verification, no pinned version, no signature check.
-
-## Impact
-
-If `install.iii.dev` were ever compromised — via DNS hijack, domain takeover, expired certificate + MITM on an untrusted network, BGP attack, or any other supply chain attack — **every new agentmemory user would execute attacker-controlled shell code** as their own user.
-
-This is the canonical "curl | sh" supply chain anti-pattern. It affected:
-- Developers running `npx @agentmemory/agentmemory` for the first time
-- CI/CD pipelines that installed agentmemory fresh
-- Docker builds that installed agentmemory as part of an image
-
-## Patches
-
-Fixed in **0.8.2**:
-
-- Removed `execSync` call entirely from `src/cli.ts`
-- CLI now uses an existing local `iii` binary if present in `$PATH`
-- Falls back to Docker Compose (`docker compose up -d`) if Docker is available
-- Shows manual install instructions if neither iii nor Docker is found:
- - `cargo install iii-engine`
- - `docker pull iiidev/iii:latest`
- - Docs link: https://iii.dev/docs
-
-## Workarounds
-
-Users on affected versions should **install iii-engine manually** and run `agentmemory --no-engine` until upgraded:
-
-```bash
-cargo install iii-engine
-npx @agentmemory/agentmemory@0.8.1 --no-engine
-```
-
-Then upgrade to 0.8.2 at the earliest opportunity.
-
-## References
-
-- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108)
-- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f)
-
-## Credit
-
-@eng-pf
diff --git a/.github/security-advisories/03-default-bind-0000.md b/.github/security-advisories/03-default-bind-0000.md
deleted file mode 100644
index f244e5d38..000000000
--- a/.github/security-advisories/03-default-bind-0000.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# GHSA Draft: agentmemory REST and stream services bound to 0.0.0.0 by default
-
-**Severity:** High · **CVSS 3.1:** 8.1 (`AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L`)
-**CWE:** [CWE-668 — Exposure of Resource to Wrong Sphere](https://cwe.mitre.org/data/definitions/668.html), [CWE-306 — Missing Authentication for Critical Function](https://cwe.mitre.org/data/definitions/306.html)
-**Affected versions:** `< 0.8.2`
-**Patched version:** `0.8.2`
-
-## Summary
-
-The default `iii-config.yaml` bound both the REST API (port 3111) and the streams server (port 3112) to `0.0.0.0`, exposing them on every network interface the host could reach. Combined with the fact that `AGENTMEMORY_SECRET` is **unset by default**, this meant any device on the same local network as a running agentmemory instance could read the entire memory store without authentication.
-
-Affected endpoints included:
-- `GET /agentmemory/export` — full dump of every captured observation, memory, session, and audit entry
-- `GET /agentmemory/sessions` — session list
-- `POST /agentmemory/smart-search` — arbitrary search over all captured content
-- `POST /agentmemory/observe` — ability to **inject** fake observations
-- `POST /agentmemory/remember` — ability to plant arbitrary memories
-- All 109 other REST endpoints
-
-## Impact
-
-A developer running agentmemory on a laptop in a coffee shop, office, or conference WiFi effectively published their entire memory store — including captured API keys, file contents, prompts, decisions, and project context — to anyone on the same network.
-
-Attackers on the same network could:
-
-1. **Exfiltrate secrets.** `curl http://:3111/agentmemory/export` downloads everything. Depending on the incompleteness of the secret redaction (see advisory #06), this could include API keys and tokens.
-2. **Inject memories.** An attacker could `POST /agentmemory/observe` or `/remember` with fake observations, poisoning the memory store so future sessions retrieve attacker-controlled context.
-3. **Pivot to other services.** The mesh sync endpoint (before the auth fix in advisory #04) accepted peer data from any source.
-
-## Patches
-
-Fixed in **0.8.2**:
-
-- `iii-config.yaml` now binds REST, streams to `127.0.0.1`
-- Viewer server already bound to `127.0.0.1`
-- New `iii-config.docker.yaml` for Docker deployments: containers bind to `0.0.0.0` internally (required for Docker networking) but host port mapping is restricted to `127.0.0.1:port` in `docker-compose.yml`
-- README and API section documentation updated to note 127.0.0.1 as the default
-
-## Workarounds
-
-Users on affected versions should manually edit their `iii-config.yaml` and change the REST and streams `host` values to `127.0.0.1`:
-
-```yaml
-modules:
- - class: modules::api::RestApiModule
- config:
- host: 127.0.0.1 # was 0.0.0.0
- - class: modules::stream::StreamModule
- config:
- host: 127.0.0.1 # was 0.0.0.0
-```
-
-And set `AGENTMEMORY_SECRET` to a strong random value to protect endpoints even if network exposure is needed.
-
-## References
-
-- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108)
-- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f)
-
-## Credit
-
-@eng-pf
diff --git a/.github/security-advisories/04-mesh-unauth.md b/.github/security-advisories/04-mesh-unauth.md
deleted file mode 100644
index d7ffe2eb8..000000000
--- a/.github/security-advisories/04-mesh-unauth.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# GHSA Draft: Unauthenticated mesh sync in agentmemory
-
-**Severity:** High · **CVSS 3.1:** 7.4 (`AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N`)
-**CWE:** [CWE-306 — Missing Authentication for Critical Function](https://cwe.mitre.org/data/definitions/306.html), [CWE-862 — Missing Authorization](https://cwe.mitre.org/data/definitions/862.html)
-**Affected versions:** `< 0.8.2`
-**Patched version:** `0.8.2`
-
-## Summary
-
-agentmemory's mesh federation feature (P2P sync between instances) accepted push/pull requests on its `/agentmemory/mesh/*` endpoints without requiring authentication. The mesh sync function also did not send any `Authorization` header when calling peer instances, meaning the federation protocol was entirely unauthenticated.
-
-## Impact
-
-Any attacker who could reach a mesh-enabled agentmemory instance could:
-
-1. **Push fake memories** via `POST /agentmemory/mesh/receive` — inject attacker-controlled observations, actions, semantic memories, and relations into the target's memory store. This poisons future retrievals and could be used to manipulate what the target's AI agent sees.
-2. **Pull the entire memory store** via `GET /agentmemory/mesh/export` — download all memories, actions, and graph data marked as mesh-shareable.
-3. **Chain with advisory #03** — combined with the default `0.0.0.0` binding, mesh endpoints were reachable from any device on the local network without any authentication.
-
-Mesh is opt-in (requires an explicit peer registration), so this affected only users who had enabled federation. But those users had no authentication at all.
-
-## Patches
-
-Fixed in **0.8.2**:
-
-- All 5 mesh REST endpoints (`mesh-register`, `mesh-list`, `mesh-sync`, `mesh-receive`, `mesh-export`) now return 503 with `"mesh requires AGENTMEMORY_SECRET"` if the secret is not configured
-- The `mem::mesh-sync` function now accepts a `meshAuthToken` parameter and **refuses to sync at all** if the token is missing
-- Outgoing push/pull requests include `Authorization: Bearer ` headers
-- Server-side, all mesh endpoints check bearer auth via the existing `checkAuth` helper
-
-## Workarounds
-
-Users on affected versions who have mesh federation enabled should:
-1. Set `AGENTMEMORY_SECRET` to a strong random value on **both** peers
-2. Restart the server
-3. Upgrade to 0.8.2 at the earliest opportunity
-
-Users who have **not** enabled mesh federation are not affected by this specific issue, but should still upgrade for the other 5 fixes.
-
-## References
-
-- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108)
-- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f)
-
-## Credit
-
-@eng-pf
diff --git a/.github/security-advisories/05-obsidian-export-traversal.md b/.github/security-advisories/05-obsidian-export-traversal.md
deleted file mode 100644
index 13c4b8e96..000000000
--- a/.github/security-advisories/05-obsidian-export-traversal.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# GHSA Draft: Arbitrary filesystem write via Obsidian export in agentmemory
-
-**Severity:** Medium · **CVSS 3.1:** 6.5 (`AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L`)
-**CWE:** [CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')](https://cwe.mitre.org/data/definitions/22.html), [CWE-73 — External Control of File Name or Path](https://cwe.mitre.org/data/definitions/73.html)
-**Affected versions:** `< 0.8.2`
-**Patched version:** `0.8.2`
-
-## Summary
-
-The `POST /agentmemory/obsidian/export` endpoint accepted a `vaultDir` parameter and passed it directly to `mkdir` and `writeFile` calls without any containment check. A caller could set `vaultDir` to any absolute path on the filesystem and agentmemory would create directories and write Markdown files there with the permissions of the process running the server.
-
-```bash
-# Example exploit payload (affected versions only)
-curl -X POST http://localhost:3111/agentmemory/obsidian/export \
- -H "Content-Type: application/json" \
- -d '{"vaultDir": "/etc/cron.d"}'
-```
-
-The content written would be agentmemory's exported memories in Markdown format, but an attacker could craft specific memory content beforehand to plant arbitrary files.
-
-## Impact
-
-When chained with advisory #03 (default `0.0.0.0` binding) or advisory #04 (unauthenticated mesh), an attacker on the local network could write arbitrary files to any filesystem location the agentmemory process had write access to.
-
-Possible exploitation paths:
-- Write to `~/.ssh/authorized_keys` — SSH key injection
-- Write to `/etc/cron.d/*` — cron job injection (if running as root)
-- Write to `~/.bashrc` or shell rc files — code execution on next shell
-- Overwrite any file the process could write to
-
-## Patches
-
-Fixed in **0.8.2**:
-
-- New `AGENTMEMORY_EXPORT_ROOT` environment variable (default: `~/.agentmemory`)
-- `vaultDir` now goes through `resolveVaultDir()` in `src/functions/obsidian-export.ts`:
- - Resolves the path with `path.resolve`
- - Checks `resolved === root || resolved.startsWith(root + path.sep)`
- - Returns `null` if the check fails, and the endpoint returns `{ success: false, error: "vaultDir must be inside AGENTMEMORY_EXPORT_ROOT" }`
-- Default export is confined to `~/.agentmemory/vault`
-- Tests added in `test/obsidian-export.test.ts` for both the custom-but-valid case and the rejection case
-
-## Known limitations
-
-`resolveVaultDir()` performs lexical containment only — it does not call `fs.realpathSync` / `fs.lstatSync`. A pre-existing symlink under `AGENTMEMORY_EXPORT_ROOT` that points outside the root can still be written through. Users who allow untrusted processes to create files inside `AGENTMEMORY_EXPORT_ROOT` should additionally run agentmemory inside a sandbox that forbids symlink creation, or file a follow-up issue requesting symlink-aware containment.
-
-## Workarounds
-
-Users on affected versions should:
-1. **Disable the Obsidian export endpoint** by setting `OBSIDIAN_AUTO_EXPORT=false` (and avoid calling `/agentmemory/obsidian/export` manually)
-2. Set `AGENTMEMORY_SECRET` so the endpoint requires bearer auth
-3. Upgrade to 0.8.2
-
-## References
-
-- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108)
-- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f)
-
-## Credit
-
-@eng-pf
diff --git a/.github/security-advisories/06-privacy-redaction-incomplete.md b/.github/security-advisories/06-privacy-redaction-incomplete.md
deleted file mode 100644
index 50c35d4b1..000000000
--- a/.github/security-advisories/06-privacy-redaction-incomplete.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# GHSA Draft: Incomplete secret redaction in agentmemory privacy filter
-
-**Severity:** Medium · **CVSS 3.1:** 6.2 (`AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N`)
-**CWE:** [CWE-532 — Insertion of Sensitive Information into Log File](https://cwe.mitre.org/data/definitions/532.html), [CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor](https://cwe.mitre.org/data/definitions/200.html)
-**Affected versions:** `< 0.8.2`
-**Patched version:** `0.8.2`
-
-## Summary
-
-agentmemory's privacy filter (`src/functions/privacy.ts`) is supposed to strip API keys, secrets, and bearer tokens from captured observations before they are stored. The filter used regex patterns to detect common token formats. Three modern token formats were missing from the patterns:
-
-1. **Bearer tokens** — `Authorization: Bearer ` headers were not matched, so any captured HTTP request or response that included an Authorization header flowed into the memory store verbatim.
-2. **OpenAI project keys** — `sk-proj-*` (the dominant OpenAI API key format since mid-2024) was not matched. The existing `sk-[A-Za-z0-9]{20,}` pattern only caught the legacy format.
-3. **GitHub fine-grained service/user tokens** — `ghs_*` and `ghu_*` were not matched. The existing `ghp_[A-Za-z0-9]{36}` pattern only caught personal access tokens.
-
-## Impact
-
-agentmemory's README explicitly claimed "Privacy first — API keys, secrets, and `` tags are stripped before anything is stored." That claim was **false** for three common token formats.
-
-Users relying on the privacy filter to protect their captured observations had a false sense of security. Tokens matching these three patterns would:
-
-1. Be captured by `PostToolUse` hooks alongside the rest of the tool output
-2. Pass through `stripPrivateData()` unmodified
-3. Be LLM-compressed and stored in the memory KV
-4. Be exposed to any attacker who could reach the `/agentmemory/export` or `/agentmemory/smart-search` endpoints
-5. Be included in Obsidian exports, mesh syncs, and CLAUDE.md bridge writes
-
-When chained with advisory #03 (default `0.0.0.0` binding), this meant network-adjacent attackers could retrieve captured Bearer tokens, OpenAI keys, and GitHub service tokens from the memory store.
-
-## Patches
-
-Fixed in **0.8.2**:
-
-New regex patterns added to `SECRET_PATTERN_SOURCES` in `src/functions/privacy.ts`:
-
-```ts
-/Bearer\s+[A-Za-z0-9._\-+/=]{20,}/gi,
-/sk-proj-[A-Za-z0-9\-_]{20,}/g,
-/(?:sk|pk|rk|ak)-[A-Za-z0-9][A-Za-z0-9\-_]{19,}/g,
-/gh[pus]_[A-Za-z0-9]{36,}/g,
-```
-
-Three new unit tests in `test/privacy.test.ts` verify each format is now stripped.
-
-## Workarounds
-
-Users on affected versions should:
-1. Avoid having agents read files or API responses containing these token formats
-2. Use the `` tag around any block containing secrets — that filter was not affected
-3. Set `AGENTMEMORY_SECRET` to restrict API access
-4. Upgrade to 0.8.2
-
-## References
-
-- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108)
-- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f)
-
-## Credit
-
-@eng-pf
From 696cf7abb8cb3295fc3bb6e84cbe7697563f14cd Mon Sep 17 00:00:00 2001
From: Rohit Ghumare <48523873+rohitg00@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:37:12 +0100
Subject: [PATCH 3/8] feat: recall quality, provenance, keyless graph, and
connector parity (#1205)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: prompt dedup, double summarize, docker-mode stop, hermetic tests
- observe: hash the hook payload when tool_input is absent so prompt_submit,
notification, and lifecycle events dedup on content instead of collapsing
onto one shared key that silently dropped every prompt after the first in
a TTL window (#1173)
- stop hook: drop the direct /agentmemory/summarize POST; /session/end
already fans out event::session::stopped which runs mem::summarize, so
every Stop dispatched two full summarizes (#1203)
- cli: refuse to adopt or signal Docker/VM port holders (com.docker.backend,
vpnkit, colima, ...) as the native engine unless --force; scope Docker-mode
teardown to agentmemory's own compose services via rm -s -f instead of an
unscoped down; reap the native worker before Docker teardown instead of
deleting worker.pid with the process still running (#1151)
- tests: isolate HOME/USERPROFILE for the whole vitest run so suites stop
reading the developer's real ~/.agentmemory/.env (#1178)
* fix(viewer): live stream port discovery, fresh tab data, honest states
- resolve the stream WebSocket target from /agentmemory/livez (new
streamsPort field) instead of viewerPort-1 arithmetic, which pointed at
the wrong server whenever the viewer bound a fallback port and silently
degraded live updates to 10s polling — verified reaching 'live' on the
fallback-port case
- refetch tab data on every tab entry; the loaded-once cache meant a
memory saved by the agent never appeared until a hard browser reload
(loading placeholders now render only on first load, so background
refreshes don't flash)
- memories: rows expand on click/Enter to the full stored record —
content, id, project, created, supersedes, files — plus a collapsible
raw JSON view
- graph: a 503 with the structured disabled body renders 'Knowledge
graph is off' with the enableHow text and docs link instead of a
'query failed / Retry' error that sends users to server logs
- sessions: cards get role=button, tabindex, Enter/Space activation, and
the detail panel scrolls into view on select; session ids truncate
head…tail so the distinguishing suffix stays visible
- style search inputs and toolbar buttons on lessons/actions/crystals/
replay (previously bare native controls); horizontal scroll containment
for narrow viewports
- demo: only print the semantic-recall success notice when the search
actually hit; on 0 hits explain the missing embedding key instead
* fix: thread agentId/project through save paths, per-session OpenCode scope
- REST /agentmemory/remember accepts and forwards agentId to mem::remember;
it previously dropped the field so per-request multi-agent scoping was
impossible over REST (#1159)
- memoryToObservation() carries the memory's agentId into the search-index
shape; dropping it made every memory invisible to agent-scoped search
(#1160)
- MCP memory_save path: the tool schema now exposes agentId, the in-worker
MCP server forwards it, and the standalone stdio package parses and
forwards both agentId and project — the stdio pipeline previously dropped
project even though its schema advertised it (#1197)
- opencode plugin: project/cwd attribution is per-session (resolved from
the session's own directory at session.created, pruned on session end)
instead of module-level state that recorded every session in a
multi-directory OpenCode process under whichever repo loaded the plugin
first (#1188)
Live-verified: memory saved with agentId=agent-alpha is returned by
smart-search for agent-alpha and hidden from agent-beta.
* feat: hybrid recall everywhere, indexed lessons, provenance, recall hygiene
- mem::search ranks through the full BM25+vector+graph fusion when the
vector index is populated (injected post-boot via setHybridRanker);
the primary recall surface was keyword-only while only smart-search
got hybrid ranking
- fusion weights normalize per item over the streams that actually
ranked it, with a small explicit cross-stream agreement bonus; the
old every-enabled-stream denominator permanently penalized
single-stream hits (the graph stream is empty on default installs).
Result order is now deterministic (score, best rank, id)
- lessons get a dedicated in-memory BM25 index built lazily from one KV
list and maintained incrementally on save/delete/decay; recall
previously listed and substring-scanned the whole corpus per query.
Confidence x recency composite scoring is unchanged
- mem::remember finds supersession candidates through the search index
(top-50) instead of walking every memory per save, with a full-scan
fallback while the index is cold; near-miss similarity (0.4-0.7)
is reported back as an advisory similarTo hint
- superseded memory versions leave the BM25 and vector indexes; the
version chain stays in KV for history, but recall no longer returns
an outdated fact as if current
- every observation and memory now carries an immutable origin block
(channel: user|agent|tool|import|shared, detail, capturedAt) stamped
at capture, save, and import, and inherited through both compression
paths — the base for trust-aware retrieval and ingest screening
- regression tests: supersede index removal, similarTo hint,
index-backed candidate discovery, lesson index recall/lazy
rebuild/delete
* feat(viewer): two-pane sessions, navigable dashboard, motion and copy polish
- sessions: list + sticky detail panel side by side above 1100px (the
detail previously rendered below the whole list, off-screen on any
real corpus); selected/hover/active states with reserved left border
so selection doesn't shift layout
- dashboard stat cards for sessions/memories/lessons/crystals/graph
navigate to their tabs (click or Enter), with hover affordance
- observation subtitles that are raw serialized tool input now display
the meaningful field (file path, command, pattern, url) instead of a
JSON blob
- expanded memory rows show the new origin provenance (channel + detail)
- motion: 160ms view entrance, live-badge pulse, both gated behind
prefers-reduced-motion; tabular numerals in tables
- mobile: header stops wrapping the dateline into the badge row
- lessons/crystals empty-state copy aligned with the header definitions
(each concept was described two conflicting ways)
* refactor: cleanup pass over the branch diff
- shared test mocks: the three new test files use test/helpers/mocks
(extended with update, store access, and an opt-in loose trigger)
instead of three diverging inline copies
- lessons: record cache beside the index takes recall to zero KV
round-trips (was up to 50 gets per call); the observation adapter
moved next to memoryToObservation so both record kinds thread new
fields in one place; dead reset export removed
- mem::search hybrid path carries the observations the ranker already
loaded instead of refetching every result (halves KV I/O on the
primary recall path); remember's candidate lookup skips ids that
cannot resolve as memories and fails open to a full scan
- fusion: derived tiebreak field no longer rides along past the sort;
comment trimmed to the non-obvious history
- cli: engine identity is a positive check (only the iii binary may be
adopted or signaled; unknown port holders are refused, not just known
VM names); worker reap extracted to one helper; demo notice picks its
branch from a hoisted count
- api: livez and health share one instanceInfo source (health now
reports streamsPort too) computed once at boot instead of rebuilding
the merged env per request
- provenance: one importOrigin factory encodes the keep-or-mark rule at
all three import sites
- opencode plugin: project resolution memoized per directory (was a
blocking git subprocess per session event); session.created uses the
entry it just built
- observe: origin channel derived from a named hook set, no nested
ternary
- viewer: toolbar buttons merged into the .btn rules, one 720px media
block, generic keyboard activation for role-carrying cards,
scroll-into-view only on the stacked layout, 5s freshness gate on tab
refetch (replay stays fetch-once, reason documented), subtitle
humanizer covers the capture-side key variants
* feat(viewer): clarity pass and ambient refresh
- health notes/alerts translate their machine slugs into sentences
(memory_heap_tight_93%_rss111mb reads as heap usage with context)
- lessons rows expand to full detail: rule, why-learned context, tags,
learned/last-confirmed times, source sessions, raw record; column
headers carry title hints for confidence and uses
- actions tab gets the same intro card as the other tabs (status flow
and frontier explained on the populated view, not just when empty)
- timeline defaults to the session with the most observations instead
of the newest, which was often a sparse just-started session
- consolidation status and top-concepts zero states explain what fills
them and which flags gate it
- ambient background: the static dot grid becomes a slowly drifting
ordered-dither field (quarter-res canvas, ~12fps, static frame under
prefers-reduced-motion, theme-aware)
- dark theme: layered near-black surfaces, hairline borders, softened
accent — replaces the flat gray borders
* feat(website): reskin on the near-black token system
- neutral token foundation in globals.css: canvas/canvas-soft/card
surfaces, hairline borders, ink/body/mute text scale, one warm accent
used sparingly, 8px card radius + pill buttons, focus-visible rings
- Inter display at weight 400 with tight tracking; mono uppercase
eyebrows and captions; sentence-case body (case normalization only,
no copy changes)
- hero: two-tone lowercase wordmark, install command as a soft input
card, ambient drifting dot field capped at 0.12 alpha with a static
frame under prefers-reduced-motion
- sections rebuilt on the card recipe: quiet background-shift hovers,
hairline data table for the comparison, segmented tabs with polarity
flip, per-vendor accent colors stripped from agent cards
- fixed two latent token misuses that resolved to nothing
- build green: 5/5 static pages, TypeScript clean
* fix(viewer): make the graph tab legible without edges
- nodes anchor to per-type cluster centers (captioned on the canvas)
whenever relations are sparse; a pure force layout with no edges was
an unlabeled scatter. Edge springs take over as real relations arrive
- labels always render on graphs of 30 or fewer visible nodes instead
of only past a zoom threshold
- sidebar explains the entities-without-relations state and what
produces edges; static legend removed (the type filter already
carries color and shape)
* fix(viewer): graph readability on sparse data
- hover focus-fade only engages when the graph has edges; with none it
faded every other node and suppressed all labels
- cluster anchor pull reduced and initial scatter widened so type
groups spread instead of collapsing into blobs
- minimum node radius raised for degree-zero nodes; cluster captions
offset above their groups
* fix(viewer): graph fits the view; site copy grounded in the repo
viewer graph:
- container height leaves room for the footer instead of running under it
- one-shot auto-fit zooms and pans to the node bounds once the layout
settles, so first paint is framed instead of adrift
- cluster captions and small-graph labels hide below readable zoom
website copy (full pass, technical register):
- every unverifiable number removed: benchmark percentages, latency
claims, press strip, testimonials, invented terminal output; the
comparison table usage dropped rather than shipping stale competitor
figures
- remaining stats are build-derived (54 MCP tools, 130 REST endpoints,
12 hooks, 1619 tests) or live from the GitHub API (stars)
- feature copy corrected against src behavior: consolidation, graph
extraction, and LLM compression activate with a provider key;
provider list completed; install step numbering fixed
- release-branch capabilities surfaced: agent-scoped save and recall,
write-time provenance channels, hybrid ranking on the primary recall
path, indexed lessons, near-duplicate save hints, superseded-version
recall hygiene, JSONL import deriving crystals and lessons
- em-dashes and slop phrasing removed throughout
* fix: restore featured strip, label collision avoidance, optional no-think
- website: FeaturedIn strip returns to the hero (its claims are the
project's own credentials); rest of the grounded-copy pass unchanged
- viewer graph: canvas cluster captions removed and labels place
greedily into free space (selected/hovered always win), so zoomed-out
views degrade to fewer labels instead of overlapping pills
- graph extraction: AGENTMEMORY_LLM_NOTHINK=1 opt-in asks local
reasoning models to skip their hidden thinking pass (several times
faster, slight quality tradeoff); documented in .env.example, default
behavior unchanged
* feat(website): testimonials return, OpenCode joins the featured connectors
- Testimonials section restored after LiveTerminal (launch-thread
quotes are the project's own record)
- OpenCode promoted from the marquee to the featured grid: it ships a
native capture plugin with per-session project attribution; fills the
empty eighth slot
- full connector roster verified against src/cli/connect (18 dedicated
adapters all present: featured grid + marquee)
* fix(viewer): official icon as the favicon (was a text placeholder)
* test(viewer): favicon assertion checks the served SVG, not a hex value
* test(viewer): favicon checks assert served SVG shape, not old artwork
* docs: readme grounded in source, changelog entry, env example consistency
* revert(website): restore measured benchmark claims and comparison table
The retrieval recall and token reduction figures are the project's own
measurements and its adoption story; earlier scrubbing was over-strict.
Backing them with a published run of the eval harness stays on the
roadmap.
* fix(website): drop the orphaned pause control on the hero field
The old animated constellation earned a pause button; the subtle dither
field does not, and prefers-reduced-motion already renders it static.
* fix(website): official OpenCode brand mark on the featured card
* chore: bump provider default models to current generations
OpenAI gpt-4o-mini to gpt-5.6-luna, Anthropic claude-sonnet-4-20250514 to claude-sonnet-5, Gemini gemini-2.5-flash to gemini-3.7-flash, MiniMax M2.7 to M3, OpenRouter default to anthropic/claude-sonnet-5. Premium cost warning matches the Sol tier; cheap-model hints lead with deepseek/deepseek-v4-flash-0731. README local picks move to qwen3 / gpt-oss / deepseek-r1 with a NOTHINK callout; cost table refreshed with verified OpenRouter list prices. Embedding defaults unchanged.
* feat: keyless heuristic graph extraction with LLM enrichment optional
Entities and co-occurrence do not need a language model: files and concepts on compressed observations already name the nodes, and appearing in the same observation is an edge. mem::graph-extract now always runs this deterministic pass, so the graph populates for keyless installs; the LLM pass layers typed relations on top only when GRAPH_EXTRACTION_ENABLED is set and a real provider exists. Session end fires extraction unconditionally.
* feat: DeepSeek Harness connector via home-level cordis patch layer
agentmemory connect dsh appends an @deepseek-ai/dsh-mcp-client row to DSH_HOME/cordis.patch.yml (default ~/.dsh), the machine-local patch layer every Harness profile loads, so the MCP tools register as mcp__agentmemory__* before the first turn. Idempotent, --force replaces the row, dry-run supported. Config shape verified against the mcp-client README and publish docs in deepseek-ai/deepseek-harness. Website agents grid and README connector table updated.
* feat: dsh --with-hooks auto-capture via Harness Claude Code bridge
DeepSeek Harness ships a first-party @deepseek-ai/dsh-hooks-claude-code plugin that runs Claude Code shaped command hooks on the harness's own interception points. connect dsh --with-hooks writes the bundled hook manifest (absolute script paths, reusing the codex-hooks merge engine) to DSH_HOME/agentmemory.hooks.json and appends a second patch row pointing the bridge at it. Auto-capture on SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop; PreCompact is outside the bridge subset and skipped. Adapter recategorized native. MCP-only installs never touch the hooks row; --force replaces both.
* feat(pi): automated connect install into pi's auto-discovery dir
connect pi was a stub printing manual copy steps because integrations/ never shipped in the npm package. The extension source now ships (integrations/pi/ in files), and the adapter copies index.ts + security.ts into ~/.pi/agent/extensions/agentmemory/, which pi auto-discovers with no settings.json edit; /reload picks it up live. Idempotent by content compare, stale copies refresh with a backup, dry-run supported. integrations/pi is also a private local pi package (pi-package keyword, pi.extensions manifest limited to index.ts so security.ts is not loaded as its own extension, peer deps on the pi core packages) so pi install ./integrations/pi works from a checkout; never published to npm. Type import moved to @earendil-works/pi-coding-agent (upstream rename).
* fix(codex): warn that hooks need one-time TUI trust approval
Codex executes only hooks with a recorded trusted_hash in config.toml, and the Hooks-need-review approval prompt appears only in the interactive TUI. A codex exec-only workflow therefore never runs freshly installed hooks and gets no signal why. connect codex --with-hooks now warns to launch codex once and choose Trust all, and to re-approve after upgrades since the refreshed absolute paths change the hash. Verified live on codex 0.147.0: before trust, exec dispatched nothing for agentmemory hooks; after TUI approval, SessionStart and UserPromptSubmit fired and the observation landed in the daemon.
* feat(pi): capture parity for the pi extension
Session registration on session_start (ordered after the health check so it fires on the first session of a fresh process), prompt capture with client-side dedup and user-channel provenance, per-tool observations from tool_result with AGENTMEMORY_TOOL_OBSERVE=0 opt-out, turn slices raised to 8000, memory_save scoped to the current project, session end plus one consolidate run on real quit only (no client summarize: session/end already fans out the summary), health accepts status ok, and refreshStatus binds the status setter before awaiting so a session replacement mid-check cannot throw a stale-context error. Live-verified on pi 0.84.2 with a local model: prompt and turn observations landed and the session closed as completed on quit.
* chore: regenerate skill reference docs
npm run skills:gen after the adapter, env, and tool changes: 20 adapters including dsh, refreshed env defaults, tool listing.
* refactor: trim oversized comments to constraint one-liners
Connector and extension comments compressed to the constraints the code cannot show; narrative headers, source citations, and restated behavior removed.
* docs(changelog): fold unreleased into the 0.9.29 release section
This branch ships as 0.9.29 (npm latest is 0.9.28; the previous 0.9.29 section was prepped but never published). One section, dated 2026-08-15, upgrade notes preserved, all 44 bullets intact.
* docs(readme): interactive-first install, dedupe, refresh stale counts
Install leads with npx and the first-run wizard (agent multi-select, provider pick, global-install offer) instead of six manual commands; Windows, EACCES, npx-cache, and iii-pin notes collapse into details blocks. Quick Start drops the duplicated install prose for an everyday-commands list. Nav drops the redundant iii Console link. Gist badge updated to the live 1.6k stars / 230 forks; test count pill and alt text updated to 1,648.
* fix: apply review round — ranking, indexes, lifecycle, connectors
Hybrid scoring normalizes once per query by the best attainable weighted score over streams that produced results, so configured stream weights survive single-stream hits; expansion merge gets the same deterministic tie-break. Graph functions register unconditionally (keyless installs previously fired mem::graph-extract at an unregistered function every session end) and the trigger goes through fireVoid; heuristic edges accumulate observation provenance for repeated pairs instead of dropping it. Supersession candidate search waits for the memory index walk (new isMemoryIndexReady signal) instead of trusting idx.size, and mem::search falls back to keyword search when the hybrid ranker throws. Lesson recall over-fetches under project/confidence filters, refreshes the index entry when reinforcement changes indexed text, and resetLessonIndex clears the cache after import and replay write lessons directly. Observe preserves primitive payloads in the dedup key so distinct prompts never collapse (regression test added). pi extension dedups prompts per session and passes project on both smart-search calls. dsh reads a corrupt hooks manifest as absent via readJsonSafe; pi install returns skipped instead of throwing when the bundled source is missing; both use the shared writeTextAtomic. Docker-mode stop clears each pidfile/state only after its shutdown succeeded and matches compose services at any indentation. Viewer livez fetch gets a 5s timeout. opencode session.deleted prunes through pruneSessionMaps (was leaking sessionProjects). MiniMax MAX_TOKENS doc says the real 4096 default. README MCP catalog: base-tools table completed to the registry's 14, the 8-tool core mode and 7-tool standalone fallback distinguished, two missing resources listed. Tests restore env vars without writing the string undefined, similarTo assertions are unconditional, vitest test home is unique per run, website meta regenerated, deprecated word-break replaced.
* docs: competitors refreshed — TencentDB Agent Memory column, entrants
TencentDB Agent Memory (TencentCloud OSS, May 2026, 22K stars) gets a full column: team memory hub captured through an LLM proxy, four asset types, PersonaMem 76% self-reported, Docker Core+Hub+Proxy stack. Stale star counts refreshed against the live API (mem0 58K to 63K, Letta 24K, Khoj 36K, supermemory 29K). A newer-entrants table covers Zep/Graphiti, Cognee, LangMem, Cloudflare Agent Memory, and Memobase, with matching choose-if sections in benchmark/COMPARISON.md. Section badge subtitle updated.
* fix: lesson index build races, rebuild ready flag, pi file backups
* docs: drop competitor links from README
* Update README.md
---
.env.example | 21 +-
CHANGELOG.md | 33 +-
README.md | 319 ++++++-----
READMEs/README.de-DE.md | 2 +-
READMEs/README.es-ES.md | 2 +-
READMEs/README.fr-FR.md | 2 +-
READMEs/README.hi-IN.md | 2 +-
READMEs/README.ja-JP.md | 2 +-
READMEs/README.ko-KR.md | 2 +-
READMEs/README.pt-BR.md | 2 +-
READMEs/README.ru-RU.md | 2 +-
READMEs/README.tr-TR.md | 2 +-
READMEs/README.zh-CN.md | 2 +-
READMEs/README.zh-TW.md | 2 +-
assets/agents/pi.svg | 1 +
assets/tags/light/section-competitors.svg | 2 +-
assets/tags/light/stat-tests.svg | 4 +-
assets/tags/section-competitors.svg | 2 +-
assets/tags/stat-tests.svg | 4 +-
benchmark/COMPARISON.md | 16 +
integrations/pi/index.ts | 117 +++-
integrations/pi/package.json | 29 +-
package.json | 1 +
plugin/opencode/agentmemory-capture.ts | 71 ++-
plugin/scripts/stop.mjs | 6 -
plugin/skills/agentmemory-agents/REFERENCE.md | 5 +-
plugin/skills/agentmemory-config/REFERENCE.md | 3 +-
.../skills/agentmemory-mcp-tools/REFERENCE.md | 2 +-
src/cli.ts | 141 ++++-
src/cli/connect/codex.ts | 5 +-
src/cli/connect/dsh.ts | 148 +++++
src/cli/connect/index.ts | 2 +
src/cli/connect/pi.ts | 105 +++-
src/cli/connect/types.ts | 6 +-
src/cli/connect/util.ts | 6 +-
src/cli/onboarding.ts | 2 +-
src/config.ts | 17 +-
src/functions/compress-synthetic.ts | 1 +
src/functions/compress.ts | 1 +
src/functions/export-import.ts | 6 +
src/functions/graph.ts | 147 ++++-
src/functions/lessons.ts | 130 ++++-
src/functions/observe.ts | 31 +-
src/functions/remember.ts | 75 ++-
src/functions/replay.ts | 8 +
src/functions/search.ts | 54 +-
src/hooks/stop.ts | 8 +-
src/index.ts | 18 +-
src/mcp/server.ts | 5 +
src/mcp/standalone.ts | 13 +
src/mcp/tools-registry.ts | 6 +
src/prompts/graph-extraction.ts | 6 +-
src/providers/index.ts | 14 +-
src/providers/minimax.ts | 4 +-
src/providers/openai.ts | 4 +-
src/state/hybrid-search.ts | 61 +-
src/state/memory-utils.ts | 24 +-
src/triggers/api.ts | 23 +-
src/triggers/events.ts | 32 +-
src/types.ts | 20 +
src/viewer/favicon.svg | 36 +-
src/viewer/index.html | 539 +++++++++++++++---
test/cli-connect.test.ts | 8 +-
test/connect-dsh.test.ts | 186 ++++++
test/connect-pi.test.ts | 133 +++++
test/fallback-model-resolution.test.ts | 26 +-
test/fetch-timeout.test.ts | 20 +-
test/graph-heuristic-extract.test.ts | 119 ++++
test/graph.test.ts | 16 +-
test/helpers/mocks.ts | 24 +-
test/lesson-index-recall.test.ts | 177 ++++++
test/minimax-provider.test.ts | 4 +-
test/observe-dedup-prompt.test.ts | 114 ++++
test/remember-supersede-recall.test.ts | 85 +++
test/viewer-security.test.ts | 6 +-
vitest.config.ts | 21 +
website/app/globals.css | 128 +++--
website/app/layout.tsx | 15 +-
website/app/opengraph-image.tsx | 63 +-
website/app/page.tsx | 5 +-
website/app/twitter-image.tsx | 2 +-
website/components/AgentInstall.module.css | 133 +++--
website/components/AgentInstall.tsx | 38 +-
website/components/Agents.module.css | 108 ++--
website/components/Agents.tsx | 41 +-
website/components/CommandCenter.module.css | 136 +++--
website/components/CommandCenter.tsx | 146 ++---
website/components/Compare.module.css | 44 +-
website/components/Compare.tsx | 9 +-
website/components/FeaturedIn.module.css | 49 +-
website/components/Features.module.css | 52 +-
website/components/Features.tsx | 76 +--
website/components/Footer.module.css | 26 +-
website/components/Footer.tsx | 12 +-
.../components/GitHubStarButton.module.css | 11 +-
website/components/GitHubStarButton.tsx | 4 +-
website/components/Hero.module.css | 68 +--
website/components/Hero.tsx | 13 +-
website/components/HeroNpxCommand.module.css | 37 +-
website/components/Install.module.css | 37 +-
website/components/Install.tsx | 26 +-
website/components/LiveTerminal.module.css | 48 +-
website/components/LiveTerminal.tsx | 12 +-
website/components/MemoryGraph.module.css | 10 +-
website/components/MemoryGraph.tsx | 113 +---
website/components/MobileNavToggle.module.css | 34 +-
website/components/Nav.module.css | 70 +--
website/components/Nav.tsx | 24 +-
website/components/Primitives.module.css | 61 +-
website/components/Primitives.tsx | 65 +--
website/components/ScrollProgress.tsx | 2 +-
website/components/Stats.module.css | 34 +-
website/components/Stats.tsx | 8 +-
website/components/Testimonials.module.css | 111 ++--
website/components/Testimonials.tsx | 2 +-
website/lib/generated-meta.json | 4 +-
website/next-env.d.ts | 1 +
website/public/opencode.png | Bin 0 -> 12923 bytes
118 files changed, 3669 insertions(+), 1504 deletions(-)
create mode 100644 src/cli/connect/dsh.ts
create mode 100644 test/connect-dsh.test.ts
create mode 100644 test/connect-pi.test.ts
create mode 100644 test/graph-heuristic-extract.test.ts
create mode 100644 test/lesson-index-recall.test.ts
create mode 100644 test/observe-dedup-prompt.test.ts
create mode 100644 test/remember-supersede-recall.test.ts
create mode 100644 vitest.config.ts
create mode 100644 website/public/opencode.png
diff --git a/.env.example b/.env.example
index 77ca0f3a3..9d346ea19 100644
--- a/.env.example
+++ b/.env.example
@@ -26,22 +26,24 @@
# The detection order is OPENAI_API_KEY → MINIMAX_API_KEY → ANTHROPIC_API_KEY
# → GEMINI_API_KEY → OPENROUTER_API_KEY → noop.
-# OPENAI_API_KEY=sk-... # Used for OpenAI-compatible embeddings today. PR #307 will extend this to chat completions (DeepSeek, SiliconFlow, vLLM, LM Studio, Ollama via `/v1`).
+# OPENAI_API_KEY=sk-... # Activates both the OpenAI-compatible LLM provider (DeepSeek, SiliconFlow, vLLM, LM Studio, Ollama via `/v1`) and OpenAI embeddings. Set OPENAI_API_KEY_FOR_LLM=false to scope it to embeddings only.
# OPENAI_BASE_URL=https://api.openai.com # Override for OpenAI-compatible providers
+# OPENAI_MODEL=gpt-5.6-luna # Default OpenAI-compatible chat model
+# OPENAI_API_KEY_FOR_LLM=false # Skip OpenAI auto-detection for LLM; key stays active for embeddings
# ANTHROPIC_API_KEY=sk-ant-...
-# ANTHROPIC_MODEL=claude-sonnet-4-20250514 # Default Anthropic model
+# ANTHROPIC_MODEL=claude-sonnet-5 # Default Anthropic model
# ANTHROPIC_BASE_URL=https://api.anthropic.com # Override for Anthropic-compatible proxies / Azure AI Foundry
# GEMINI_API_KEY=... # Either env name works; GEMINI_API_KEY takes precedence
# GOOGLE_API_KEY=... # Alias for GEMINI_API_KEY when set alone (emits a one-time stderr hint)
-# GEMINI_MODEL=gemini-2.5-flash # Default Gemini model (auto-detected GA model)
+# GEMINI_MODEL=gemini-3.7-flash # Default Gemini model (current stable Flash)
# OPENROUTER_API_KEY=sk-or-...
-# OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514
+# OPENROUTER_MODEL=anthropic/claude-sonnet-5
# MINIMAX_API_KEY=...
-# MINIMAX_MODEL=MiniMax-M2.7
+# MINIMAX_MODEL=MiniMax-M3
# MAX_TOKENS=4096 # Cap LLM completion tokens for compression / summarise calls
@@ -111,6 +113,13 @@
# CONSOLIDATION_DECAY_DAYS=30 # Age (days) after which non-reinforced memories decay during consolidation
# GRAPH_EXTRACTION_ENABLED=true # Extract concept-graph edges on remember; powers the graph-traversal recall path
# GRAPH_EXTRACTION_BATCH_SIZE=8 # Memories per graph-extraction batch
+
+# Local reasoning models only: set to 1 to ask the model to skip its
+# hidden thinking pass during graph extraction. Extraction runs several
+# times faster; relation quality can drop slightly. Leave unset to let
+# the model think (default).
+# AGENTMEMORY_LLM_NOTHINK=1
+
# AGENTMEMORY_REFLECT=true # Periodically auto-synthesize lessons from memories
# AGENTMEMORY_DROP_STALE_INDEX=true # Drop on-disk BM25 / vector index on startup if dim guard fires (#248). Recovery toggle for stuck-state debugging.
# AGENTMEMORY_IMAGE_EMBEDDINGS=true # Enable image embeddings when an image provider is present (experimental).
@@ -119,7 +128,7 @@
# 6. CLI / runtime knobs
# -----------------------------------------------------------------------------
-# AGENTMEMORY_TOOLS=all # core (7 tools, default) | all (51 tools) — surface exposed to MCP clients
+# AGENTMEMORY_TOOLS=core # all (54 tools, default) | core (8 tools): surface exposed to MCP clients
# AGENTMEMORY_SLOTS=memory # Comma-separated plugin slot names the CLI should claim
# AGENTMEMORY_DEBUG=1 # Trace MCP shim probe + standalone fallback decisions to stderr
# AGENTMEMORY_FORCE_PROXY=1 # Skip the MCP shim livez probe and trust AGENTMEMORY_URL (for sandboxed MCP clients that can't reach localhost)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e877b7fca..89da67258 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,11 +4,9 @@ All notable changes to agentmemory will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [0.9.29] — 2026-08-15
-## [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.
+Release wave in two parts. Recall quality: hybrid ranking reaches the primary recall path, lessons get a real index, every record learns where it came from, the knowledge graph populates keyless, and agent scoping threads through all save paths — plus connector parity for pi and Codex, a new DeepSeek Harness connector, current provider model defaults, and a viewer clarity pass. Foundation: the `.env` file now applies everywhere, imports become searchable, consolidation runs on session stop, twelve MCP-only agents get activated on connect, and every capture surface agrees on what "project" means. No breaking changes; read the upgrade notes for behavior changes you will notice.
### Upgrade notes
@@ -19,6 +17,14 @@ Patch release: the `.env` file now actually applies everywhere, imports become s
### Added
+- **Write-time provenance on every record.** Each observation and memory carries an immutable origin block (channel `user` / `agent` / `tool` / `import` / `shared`, detail, capturedAt) stamped at capture, save, and import, and inherited through both compression paths. The base for trust-aware retrieval and ingest screening.
+- **`similarTo` advisory hint on save.** `mem::remember` reports a near-miss similarity match (0.4 to 0.7) back to the caller so agents can spot near-duplicates without the write being blocked.
+- **`AGENTMEMORY_LLM_NOTHINK=1`** (opt-in): asks local reasoning models to skip their hidden thinking pass during graph extraction. Extraction runs faster; relation quality can drop slightly. Default behavior unchanged; documented in `.env.example`.
+- **Keyless graph extraction.** `mem::graph-extract` always runs a deterministic structural pass first: files and concepts on compressed observations become nodes, and co-occurrence within an observation becomes a `related_to` edge. The graph now populates without any LLM key; `GRAPH_EXTRACTION_ENABLED` plus a provider key gates only the LLM pass that layers typed relations (fixes, depends_on, causes) on top. Session end fires extraction unconditionally.
+- **pi extension: capture parity with the Claude Code plugin.** Session registration on start (after the health check populates reachability, so it fires on the first session of a fresh process), prompt capture on submit (deduped in a 5-minute client window against auto-retry re-submissions; stored with user-channel provenance), per-tool observations from `tool_result` (server `inferType` classifies command_run / file_edit / file_read; `AGENTMEMORY_TOOL_OBSERVE=0` opts out), turn capture slices raised 500/4000 → 8000/8000, `memory_save` scoped to the current project instead of the global bucket, session end + one cross-session consolidate run on real quit only (`/new`, `/resume`, `/fork`, reloads excluded; no client-side summarize call — `session/end` already fans out the summary, avoiding the double-summarize the Stop hook had), status checks accept `status: "ok"`, and the status refresh no longer throws a stale-context error when the session is replaced mid-health-check. Live-verified on pi v0.84.2: prompt + turn observations landed and the session closed as `completed` on quit. Codex executes only hooks with a recorded `trusted_hash` and shows its "Hooks need review" approval prompt exclusively in the interactive TUI, so a `codex exec`-only workflow left the freshly installed hooks silently inert. `connect codex --with-hooks` now warns to launch `codex` once and choose "Trust all and continue" (and to re-approve after upgrades, since refreshed paths change the hash). Found by live-testing the documented flow end to end.
+- **`connect pi` actually installs.** The pi adapter was a stub that printed manual copy instructions because `integrations/` never shipped in the npm package. The extension source now ships, and `connect pi` copies it into `~/.pi/agent/extensions/agentmemory/`, which pi auto-discovers — no settings.json edit, `/reload` picks it up live. Idempotent by content compare; `--force` and stale copies refresh with a backup. `integrations/pi` is also a proper pi package now (`pi-package` keyword, `pi.extensions` manifest, peer deps on `@earendil-works/pi-coding-agent` + `typebox`; private, local installs only — `pi install ./integrations/pi` from a checkout), and the extension's type import moved off the renamed upstream package name.
+- **DeepSeek Harness connector.** `agentmemory connect dsh` appends an `@deepseek-ai/dsh-mcp-client` row to the home-level `$DSH_HOME/cordis.patch.yml`, the machine-local patch layer every Harness profile loads. `--with-hooks` adds full auto-capture: the bundled Claude Code hook scripts run through Harness's first-party `@deepseek-ai/dsh-hooks-claude-code` bridge (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop) via a manifest written to `$DSH_HOME/agentmemory.hooks.json` with absolute script paths. Idempotent, `--force` replaces the rows, honors `DSH_HOME`.
+- **Viewer clarity pass.** Two-pane session explorer (list beside a sticky detail panel on wide screens), dashboard stat cards that navigate to their tabs, memory and lesson rows that expand to the full stored record with raw JSON and origin provenance, type-clustered graph layout with label collision avoidance when relations are sparse, health notes translated from machine slugs into sentences, honest zero states for consolidation and graph, and the official icon as the favicon.
- `--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)
@@ -28,8 +34,23 @@ Patch release: the `.env` file now actually applies everywhere, imports become s
- `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)
+### Changed
+
+- **Provider default models bumped to current generations.** OpenAI `gpt-4o-mini` → `gpt-5.6-luna`, Anthropic `claude-sonnet-4-20250514` (deprecated upstream, retires 2026-06-15) → `claude-sonnet-5`, Gemini `gemini-2.5-flash` → `gemini-3.7-flash` (current stable Flash), MiniMax `MiniMax-M2.7` → `MiniMax-M3`, OpenRouter `anthropic/claude-sonnet-4-20250514` → `anthropic/claude-sonnet-5`. The premium-model cost warning now also matches OpenAI's Sol flagship tier, and its cheap-alternative hint leads with `deepseek/deepseek-v4-flash-0731`. Explicit `*_MODEL` env overrides are unaffected. Embedding defaults are unchanged (`text-embedding-3-small`, `gemini-embedding-001`, local MiniLM are all current). README local-model picks refreshed to the Qwen 3 / gpt-oss / DeepSeek R1 generation.
+- 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)
+
### Fixed
+- **Hybrid ranking on the primary recall path.** `mem::search` (behind `memory_recall`) now ranks through the full BM25 + vector + graph fusion when the vector index is populated; it was keyword-only while only smart-search got hybrid ranking. Fusion weights normalize per item over the streams that actually ranked it, with an explicit cross-stream agreement bonus, replacing the every-enabled-stream denominator that permanently penalized single-stream hits. Result order is deterministic (score, best rank, id).
+- **Indexed lesson recall.** Lessons get a dedicated in-memory BM25 index built lazily from one KV list and maintained incrementally on save, delete, and decay; recall previously listed and substring-scanned the whole corpus per query. A record cache beside the index takes recall to zero KV round-trips.
+- **Superseded versions leave recall.** Superseded memory versions are removed from the BM25 and vector indexes; the version chain stays in KV for history, but recall no longer returns an outdated fact as if current. `mem::remember` also finds supersession candidates through the search index (top 50) instead of walking every memory per save, with a full-scan fallback while the index is cold.
+- **`agentId` threads through every save path** ([#1159](https://github.com/rohitg00/agentmemory/issues/1159), [#1160](https://github.com/rohitg00/agentmemory/issues/1160), [#1197](https://github.com/rohitg00/agentmemory/issues/1197)). REST `/agentmemory/remember` forwards `agentId` instead of dropping it; `memoryToObservation()` carries the memory's `agentId` into the search-index shape so saved memories are visible to agent-scoped search; the MCP `memory_save` schema exposes `agentId` and the standalone stdio package forwards both `agentId` and `project`.
+- **Per-session project attribution in the OpenCode plugin** ([#1188](https://github.com/rohitg00/agentmemory/issues/1188)). Project and cwd resolve from each session's own directory at `session.created` (pruned on session end) instead of module-level state that filed every session in a multi-directory OpenCode process under whichever repo loaded the plugin first.
+- **Prompt dedup no longer swallows prompts** ([#1173](https://github.com/rohitg00/agentmemory/issues/1173)). Hooks hash the payload when `tool_input` is absent, so prompt_submit, notification, and lifecycle events dedup on content instead of collapsing onto one shared key that silently dropped every prompt after the first in a TTL window.
+- **Stop hook no longer summarizes twice** ([#1203](https://github.com/rohitg00/agentmemory/issues/1203)). The direct `/agentmemory/summarize` POST is gone; `/session/end` already fans out `event::session::stopped`, which runs `mem::summarize`.
+- **Safe Docker-mode stop** ([#1151](https://github.com/rohitg00/agentmemory/issues/1151)). The CLI refuses to adopt or signal Docker/VM port holders (com.docker.backend, vpnkit, colima) as the native engine unless `--force`; Docker-mode teardown is scoped to agentmemory's own compose services instead of an unscoped `down`; the native worker is reaped before Docker teardown instead of deleting `worker.pid` with the process still running.
+- **Viewer live stream and freshness.** The stream WebSocket target resolves from `/agentmemory/livez` (new `streamsPort` field) instead of viewerPort-1 arithmetic, which pointed at the wrong server whenever the viewer bound a fallback port and silently degraded live updates to polling. Tab data refetches on entry (with a freshness gate), so a memory saved by the agent appears without a hard reload.
+- **Hermetic tests** ([#1178](https://github.com/rohitg00/agentmemory/issues/1178)). HOME/USERPROFILE are isolated for the whole vitest run so suites stop reading the developer's real `~/.agentmemory/.env`.
- 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)
@@ -47,10 +68,6 @@ Patch release: the `.env` file now actually applies everywhere, imports become s
- 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.
diff --git a/README.md b/README.md
index a716d8f1c..83fec8e02 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -30,7 +30,7 @@
-
+
@@ -50,7 +50,7 @@
-
+
@@ -66,7 +66,6 @@
How It Works •
MCP •
Viewer •
- iii Console •
Powered by iii •
Config •
API
@@ -76,34 +75,58 @@
## Install
-Fastest path if you use a coding agent: hand it this one instruction and it installs, wires, and verifies agentmemory end to end.
+One command:
-> Retrieve and follow the instructions at: https://raw.githubusercontent.com/rohitg00/agentmemory/main/INSTALL_FOR_AGENTS.md
+```bash
+npx @agentmemory/agentmemory
+```
+
+The first run is an interactive setup: pick the agents to wire (Claude Code, Cursor, Codex, Gemini CLI, OpenCode, ...), pick an LLM provider or stay keyless, and it seeds the config, starts the memory server on `:3111`, and offers to install globally so the bare `agentmemory` command works everywhere afterwards.
-On Windows the fast path is WSL2. Native Windows engine setup is manual (about 10 to 20 minutes) and `agentmemory connect` is currently unsupported there. See the [Windows notes](#windows) below for the step-by-step.
+Then prove recall works and give your agent its skills:
```bash
-npm install -g @agentmemory/agentmemory # once — bare `agentmemory` on PATH
-# If you hit EACCES on macOS/Linux system Node installs, retry with:
-# sudo npm install -g @agentmemory/agentmemory
-agentmemory # start the memory server on :3111
-agentmemory demo # seed sample sessions + prove recall
-agentmemory demo --serve # one command: boot server, run demo, tear down (no second terminal)
-agentmemory connect claude-code # wire MCP into your agent (also: copilot-cli, codex, cursor, gemini-cli, ...)
-npx skills add rohitg00/agentmemory -y # install 15 native skills (8 you can invoke, 7 reference) so your agent knows when to use the tools
+agentmemory demo --serve # seed sample sessions + watch recall find them
+npx skills add rohitg00/agentmemory -y # 15 native skills so your agent knows when to reach for memory
```
-Or via `npx` (no install):
+Prefer to let a coding agent do the whole thing? Hand it one instruction:
+
+> Retrieve and follow the instructions at: https://raw.githubusercontent.com/rohitg00/agentmemory/main/INSTALL_FOR_AGENTS.md
+
+Wire more agents any time with `agentmemory connect ` — 20 adapters listed at [Works with every agent](#works-with-every-agent). Full command reference at [Quick Start](#quick-start).
+
+
+Windows
+
+The fast path is WSL2. Native Windows engine setup is manual (about 10 to 20 minutes) and `agentmemory connect` is currently unsupported there. See the [Windows notes](#windows) for the step-by-step.
+
+
+
+
+Global install / EACCES
```bash
-npx @agentmemory/agentmemory
+npm install -g @agentmemory/agentmemory
+# If you hit EACCES on macOS/Linux system Node installs:
+sudo npm install -g @agentmemory/agentmemory
```
-Heads-up — npx caches per version. If a bare `npx @agentmemory/agentmemory` serves an older release, force the latest with `npx -y @agentmemory/agentmemory@latest`, or clear the cache once with `rm -rf ~/.npm/_npx` (macOS/Linux; on Windows delete `%LOCALAPPDATA%\npm-cache\_npx`). The first npx run from v0.9.16+ prompts to install globally inline so the bare `agentmemory` command works everywhere afterwards.
+
+
+
+npx serves an old version
+
+npx caches per version. Force the latest with `npx -y @agentmemory/agentmemory@latest`, or clear the cache once with `rm -rf ~/.npm/_npx` (macOS/Linux; on Windows delete `%LOCALAPPDATA%\npm-cache\_npx`).
-Already running your own `iii` engine? agentmemory pins iii-engine v0.11.2 and won't attach to a different version (the worker can't speak another engine's protocol). Stop the other engine, then run `npx -y @agentmemory/agentmemory@latest` — it installs and runs the pinned v0.11.2 in `~/.agentmemory/bin`, leaving your own `iii` untouched.
+
+
+
+Already running your own iii engine
-Full options at [Quick Start](#quick-start) below. Agent-specific wiring at [Works with every agent](#works-with-every-agent).
+agentmemory pins iii-engine v0.11.2 and won't attach to a different version (the worker can't speak another engine's protocol). Stop the other engine, then run `npx -y @agentmemory/agentmemory@latest`. It installs and runs the pinned v0.11.2 in `~/.agentmemory/bin`, leaving your own `iii` untouched.
+
+
---
@@ -218,7 +241,7 @@ agentmemory works with any agent that supports hooks, MCP, or REST API. All agen
You explain the same architecture every session. You re-discover the same bugs. You re-teach the same preferences. Built-in memory (CLAUDE.md, .cursorrules) caps out at 200 lines and goes stale. agentmemory fixes this. It silently captures what your agent does, compresses it into searchable memory, and injects the right context when the next session starts. One command. Works across agents.
-**What changes:** Session 1 you set up JWT auth. Session 2 you ask for rate limiting. The agent already knows your auth uses jose middleware in `src/middleware/auth.ts`, your tests cover token validation, and you chose jose over jsonwebtoken for Edge compatibility. No re-explaining. No copy-pasting. The agent just *knows*.
+**What changes:** Session 1 you set up JWT auth. Session 2 you ask for rate limiting. The agent already knows your auth uses jose middleware in `src/middleware/auth.ts`, your tests cover token validation, and you chose jose over jsonwebtoken for Edge compatibility, with no re-explaining and no copy-pasting.
```bash
npx @agentmemory/agentmemory
@@ -250,7 +273,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
| **agentmemory hybrid** | **0.240** | **1.000** | **15 / 15** | 14 ms |
| grep baseline | 0.227 | 0.967 | 15 / 15 | 0 ms |
-100% top-5 hit rate at the **P@5 math ceiling** for this corpus (0.240, see scorecard). Hybrid retrieves every gold session; grep misses 1 of 2 gold on the multi-session temporal query. Lift is **recall + temporal**, not aggregate precision — this benchmark is small + gold-sparse, the larger LongMemEval-S below differentiates better. Full per-type breakdown + correction note: [`docs/benchmarks/2026-05-20-coding-agent-life-v1.md`](docs/benchmarks/2026-05-20-coding-agent-life-v1.md).
+100% top-5 hit rate at the **P@5 math ceiling** for this corpus (0.240, see scorecard). Hybrid retrieves every gold session; grep misses 1 of 2 gold on the multi-session temporal query. Lift is **recall + temporal**, not aggregate precision. This benchmark is small and gold-sparse; the larger LongMemEval-S below differentiates better. Full per-type breakdown + correction note: [`docs/benchmarks/2026-05-20-coding-agent-life-v1.md`](docs/benchmarks/2026-05-20-coding-agent-life-v1.md).
**LongMemEval-S** (ICLR 2025, 500 questions)
@@ -275,9 +298,9 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
-> Embedding model: `all-MiniLM-L6-v2` (local, free, no API key). Full reports: [`benchmark/LONGMEMEVAL.md`](benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](benchmark/QUALITY.md), [`benchmark/SCALE.md`](benchmark/SCALE.md). Competitor comparison: [`benchmark/COMPARISON.md`](benchmark/COMPARISON.md) covering agentmemory vs mem0, Letta, Khoj, supermemory, MemPalace, Hippo.
+> Embedding model: `all-MiniLM-L6-v2` (local, free, no API key). Full reports: [`benchmark/LONGMEMEVAL.md`](benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](benchmark/QUALITY.md), [`benchmark/SCALE.md`](benchmark/SCALE.md). Competitor comparison: [`benchmark/COMPARISON.md`](benchmark/COMPARISON.md) covering agentmemory vs mem0, Letta, Khoj, supermemory, TencentDB Agent Memory, MemPalace, Zep/Graphiti, Cognee, Hippo.
-**Reproduce locally:** [`eval/README.md`](eval/README.md) — adapter-pluggable harness for LongMemEval `_s` (public 500-Q) + `coding-agent-life-v1` (in-house 15-session corpus). Grep / vector / agentmemory adapters score side-by-side, NDJSON output, published scorecards land in [`docs/benchmarks/`](docs/benchmarks/).
+**Reproduce locally:** [`eval/README.md`](eval/README.md), an adapter-pluggable harness for LongMemEval `_s` (public 500-Q) + `coding-agent-life-v1` (in-house 15-session corpus). Grep / vector / agentmemory adapters score side-by-side, NDJSON output, published scorecards land in [`docs/benchmarks/`](docs/benchmarks/).
**Pairs with [codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything), and [Graphify](https://github.com/safishamsi/graphify).** Code-graph indexing, multi-agent build pipelines, and broader knowledge graphs across docs / PDFs / images / videos. agentmemory remembers the work; those three projects light up the rest of the context layer. Recipes + question-routing table: [`docs/recipes/pairings.md`](docs/recipes/pairings.md).
@@ -289,10 +312,11 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
agentmemory
-mem0 (58K ⭐)
-Letta / MemGPT (23K ⭐)
-Khoj (35K ⭐)
-supermemory (26K ⭐)
+mem0 (63K ⭐)
+Letta / MemGPT (24K ⭐)
+Khoj (36K ⭐)
+supermemory (29K ⭐)
+TencentDB Agent Memory (22K ⭐)
MemPalace (54K ⭐)
oracleagentmemory
Hippo
@@ -305,6 +329,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Full agent runtime
Personal AI
Memory API + app
+Team memory hub (LLM proxy)
Vector memory (OSS)
Memory engine (Oracle DB)
Memory system
@@ -317,6 +342,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
83.2% (LoCoMo)
N/A
Self-reported
+PersonaMem 76% (self-reported)
~96.6% (self-reported)
94.4% (self-reported)
N/A
@@ -329,6 +355,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Agent self-edits
Manual
API-side extraction
+Proxy interception (base-URL swap)
Manual
API extraction
Manual
@@ -341,6 +368,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Vector (archival)
Semantic
Vector + RAG
+4 asset types (Chat / Skill / Wiki / CodeGraph)
Vector-only
Vector + semantic
Decay-weighted
@@ -353,6 +381,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Within Letta runtime only
No
No
+Team roles + shared assets
No
Scoped only
Multi-agent shared
@@ -365,6 +394,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
High (must use Letta)
Standalone
None
+Proxy fronts every model call
None
Oracle Database
None
@@ -377,6 +407,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Postgres + vector DB
Multiple
Managed cloud
+Docker stack (Core + Hub + Proxy)
Vector store
Oracle AI Database
None
@@ -389,6 +420,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Agent-managed
Manual
Auto-forget
+Manual review; auto-routing in progress
None
Not stated
Decay + consolidation
@@ -401,6 +433,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Core memory in context
Varies
Cloud pricing
+Not stated
No token budget
LLM-backed (varies)
Varies
@@ -413,6 +446,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Cloud dashboard
Web UI
Cloud dashboard
+Hub web UI
No
No
No
@@ -425,6 +459,7 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
Optional
Yes
No (cloud-only)
+Yes (Docker)
Yes
Yes (Oracle DB)
Yes
@@ -432,7 +467,16 @@ Latest release notes: [CHANGELOG.md](CHANGELOG.md).
-Benchmark note: only agentmemory's R@5 is our own measured result (LongMemEval-S, reproducible from benchmark/COMPARISON.md ). The mem0 and Letta figures are their published LoCoMo numbers (a different dataset); the MemPalace, supermemory, and oracleagentmemory figures are vendor self-reported claims we have not independently reproduced (oracleagentmemory's run used GPT-5.5 against an Oracle AI Database). Shown side by side for ballpark only, not a head-to-head on identical data. Star counts are approximate and drift over time.
+Benchmark note: only agentmemory's R@5 is our own measured result (LongMemEval-S, reproducible from benchmark/COMPARISON.md ). The mem0 and Letta figures are their published LoCoMo numbers (a different dataset); the MemPalace, supermemory, TencentDB (PersonaMem), and oracleagentmemory figures are vendor self-reported claims we have not independently reproduced (oracleagentmemory's run used GPT-5.5 against an Oracle AI Database). Shown side by side for ballpark only, not a head-to-head on identical data. Star counts are approximate and drift over time.
+
+**Newer entrants** worth knowing, compared in depth in [`benchmark/COMPARISON.md`](benchmark/COMPARISON.md):
+
+| System | ⭐ | Angle |
+|--------|---|-------|
+| Zep / Graphiti | 30K | Temporal knowledge graph; strongest published temporal-query results (LongMemEval 63.8%), but graph builds asynchronously so fresh facts can lag |
+| Cognee | 30K | Document-to-knowledge-graph ingestion, Python-only, built for structured entity extraction rather than session capture |
+
+None of these auto-capture from coding-agent hooks, ship a local-first viewer, or run keyless — the combination agentmemory is built around.
---
@@ -450,39 +494,27 @@ npx @agentmemory/agentmemory
npx @agentmemory/agentmemory demo
```
-`demo` seeds 3 realistic sessions (JWT auth, N+1 query fix, rate limiting) and runs semantic searches against them. You'll see it find "N+1 query fix" when you search "database performance optimization" — keyword matching can't do that.
+`demo` seeds 3 realistic sessions (JWT auth, N+1 query fix, rate limiting) and runs semantic searches against them. You'll see it find "N+1 query fix" when you search "database performance optimization", which keyword matching cannot do.
Open `http://localhost:3113` to watch the memory build live.
-### Recommended: install globally
+### Everyday commands
-`npx` caches per-version. If you ran `npx @agentmemory/agentmemory@0.9.14` last week, a bare `npx @agentmemory/agentmemory` may serve the stale 0.9.14 from `~/.npm/_npx/`, not the latest release. Install once and the bare `agentmemory` command works everywhere:
+Install and setup live in [Install](#install) above (the first run walks you through it). Day to day:
```bash
-npm install -g @agentmemory/agentmemory
-# If you hit EACCES on macOS/Linux system Node installs, retry with:
-# sudo npm install -g @agentmemory/agentmemory
-agentmemory # start the server (same as the npx form)
+agentmemory # start the server
agentmemory stop # tear it down
-agentmemory remove # uninstall everything we created
-agentmemory connect claude-code # wire one agent
+agentmemory connect # wire another agent
agentmemory doctor # interactive diagnostics + fix prompts
+agentmemory remove # uninstall everything we created
```
-From v0.9.16 onward, the first npx run prompts you to install globally inline — answer `Y` once and you're set. If you skip, fall back to either of these for a fresh fetch:
-
-```bash
-npx -y @agentmemory/agentmemory@latest # forces latest from npm (cross-platform)
-rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # macOS/Linux only (POSIX shell)
-```
-
-On Windows / PowerShell, the equivalent cache clear is `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` — the `npx -y ...@latest` form above is the cross-platform option.
-
### Session Replay
-Every session agentmemory records is replayable. Open the viewer, pick the **Replay** tab, and scrub through the timeline: prompts, tool calls, tool results, and responses render as discrete events with play/pause, speed control (0.5×–4×), and keyboard shortcuts (space to toggle, arrows to step).
+Every session agentmemory records is replayable. Open the viewer, pick the **Replay** tab, and scrub through the timeline: prompts, tool calls, tool results, and responses render as discrete events with play/pause, speed control (0.5x to 4x), and keyboard shortcuts (space to toggle, arrows to step).
-Already have older Claude Code JSONL transcripts you want to bring in?
+To bring in older Claude Code JSONL transcripts:
```bash
# Import everything under the default ~/.claude/projects
@@ -492,7 +524,7 @@ npx @agentmemory/agentmemory import-jsonl
npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl
```
-Imported sessions show up in the Replay picker alongside native ones. Under the hood each entry routes through the `mem::replay::load`, `mem::replay::sessions`, and `mem::replay::import-jsonl` iii functions — no side-channel servers.
+Imported sessions show up in the Replay picker alongside native ones. Under the hood each entry routes through the `mem::replay::load`, `mem::replay::sessions`, and `mem::replay::import-jsonl` iii functions, with no side-channel servers. Each imported transcript is indexed for search, stamped with origin channel `import`, and mined for a session crystal and lessons.
> **Heads-up if you rely on `import-jsonl` as your primary capture path:** Claude Code's `cleanupPeriodDays` (in `~/.claude/settings.json`, default **30**) auto-deletes JSONL transcripts older than that window from `~/.claude/projects/`. If you install agentmemory fresh on a months-old Claude Code history, anything older than 30 days is already gone before the first import. Either run `import-jsonl` on a cron, raise `cleanupPeriodDays` to something higher, or wire the auto-capture hooks (the default plugin install path) so each turn lands in agentmemory while the session is live and the JSONL cleanup stops mattering.
@@ -635,7 +667,7 @@ This is **complementary** to `agentmemory connect `:
- `agentmemory connect ` writes the MCP server config so the tools are available.
- `npx skills add rohitg00/agentmemory` installs the skills so the agent knows when to call them.
-For the few agents the skills CLI doesn't cover yet (Zed v1.3.x and below), drop the 15 SKILL.md files under the agent's native skill directory yourself — same format works everywhere.
+For the few agents the skills CLI doesn't cover yet (Zed v1.3.x and below), drop the 15 SKILL.md files under the agent's native skill directory yourself; the same format works everywhere.
#### Standard MCP block
@@ -652,7 +684,7 @@ The agentmemory entry is the **same MCP server block** across every host that us
}
```
-**Merge this entry into the existing `mcpServers` object** in the host's config file — don't replace the file. If the file already has other servers, add `agentmemory` next to them as another key inside `mcpServers`. If `mcpServers` is missing entirely, paste the block inside `{ "mcpServers": { ... } }`. The `${VAR}` placeholders inherit `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` from the shell at MCP-server launch — unset vars pass empty strings and the shim falls back to `http://localhost:3111`. One wired entry covers both local and remote (k8s / reverse-proxied) deployments.
+**Merge this entry into the existing `mcpServers` object** in the host's config file; don't replace the file. If the file already has other servers, add `agentmemory` next to them as another key inside `mcpServers`. If `mcpServers` is missing entirely, paste the block inside `{ "mcpServers": { ... } }`. The `${VAR}` placeholders inherit `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` from the shell at MCP-server launch; unset vars pass empty strings and the shim falls back to `http://localhost:3111`. One wired entry covers both local and remote (k8s / reverse-proxied) deployments.
| Agent | Config file | Notes |
|---|---|---|
@@ -665,21 +697,22 @@ The agentmemory entry is the **same MCP server block** across every host that us
| **GitHub Copilot CLI (full plugin)** | Copilot plugin install | `copilot plugin install rohitg00/agentmemory:plugin` for the plugin from the GitHub subdir. |
| **OpenClaw** | OpenClaw MCP config | Same `mcpServers` block, or use the deeper [memory plugin](integrations/openclaw/). |
| **Codex CLI (MCP only)** | `.codex/config.toml` | TOML shape: `codex mcp add agentmemory -- npx -y @agentmemory/mcp`, or add `[mcp_servers.agentmemory]` manually. |
-| **Codex CLI (full plugin)** | Codex plugin marketplace | `codex plugin marketplace add rohitg00/agentmemory` then `codex plugin add agentmemory@agentmemory`. Registers MCP + 6 lifecycle hooks (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 15 skills. On Codex Desktop, also run `agentmemory connect codex --with-hooks` until [openai/codex#16430](https://github.com/openai/codex/issues/16430) lands — plugin hooks are currently silent there. |
-| **OpenCode (MCP only)** | `opencode.json` | Different shape — top-level `mcp` key, command as array: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. |
-| **OpenCode (full plugin)** | `plugin/opencode/` | 22 auto-capture hooks covering session lifecycle, messages, tools, errors. Two slash commands (`/recall`, `/remember`). Copy `plugin/opencode/` into your OpenCode workspace and add the plugin entry to `opencode.json`. See [`plugin/opencode/README.md`](plugin/opencode/README.md) for the full hook table + gap analysis. |
-| **pi** | `~/.pi/agent/extensions/agentmemory` | Copy [`integrations/pi`](integrations/pi/) and restart pi. |
+| **Codex CLI (full plugin)** | Codex plugin marketplace | `codex plugin marketplace add rohitg00/agentmemory` then `codex plugin add agentmemory@agentmemory`. Registers MCP + 6 lifecycle hooks (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 15 skills. On Codex Desktop, also run `agentmemory connect codex --with-hooks` until [openai/codex#16430](https://github.com/openai/codex/issues/16430) lands; plugin hooks are currently silent there. |
+| **OpenCode (MCP only)** | `opencode.json` | Different shape: top-level `mcp` key, command as array: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. |
+| **OpenCode (full plugin)** | `plugin/opencode/` | 22 auto-capture hooks covering session lifecycle, messages, tools, errors. Project attribution is per-session, so one OpenCode process spanning several repositories files each session under its own project. Two slash commands (`/recall`, `/remember`). Copy `plugin/opencode/` into your OpenCode workspace and add the plugin entry to `opencode.json`. See [`plugin/opencode/README.md`](plugin/opencode/README.md) for the full hook table + gap analysis. |
+| **pi** | `~/.pi/agent/extensions/agentmemory` | `agentmemory connect pi` installs the bundled extension into pi's auto-discovery directory (recall on agent start, capture on agent end, `memory_search` / `memory_save` / `memory_health` tools, `/agentmemory-status`). `/reload` in a running pi picks it up. [`integrations/pi`](integrations/pi/) is also a pi package (`pi install ./integrations/pi` from a checkout). |
| **Hermes Agent** | `~/.hermes/config.yaml` | Use the deeper [memory provider plugin](integrations/hermes/) with `memory.provider: agentmemory`. |
-| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` writes the standard `mcpServers` block. Hook payload is field-compatible with Claude Code, so the existing 12-hook scripts work without modification — wire them via the `hooks` section in the same `settings.json`. |
+| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` writes the standard `mcpServers` block. Hook payload is field-compatible with Claude Code, so the existing 12-hook scripts work without modification; wire them via the `hooks` section in the same `settings.json`. |
| **Antigravity** (replaces Gemini CLI) | `mcp_config.json` (in Antigravity's User dir) | `agentmemory connect antigravity` writes the standard `mcpServers` block. macOS: `~/Library/Application Support/Antigravity/User/`. Linux: `~/.config/Antigravity/User/`. Use after the 2026-06-18 Gemini CLI sunset. |
-| **Antigravity CLI** (`agy`) | `~/.gemini/config/mcp_config.json` | `agentmemory connect antigravity-cli` — the `agy` CLI keeps its own config under `~/.gemini/`, separate from the Antigravity IDE above. Pass `--with-hooks` for native auto-capture via `~/.gemini/config/hooks.json`. |
+| **Antigravity CLI** (`agy`) | `~/.gemini/config/mcp_config.json` | `agentmemory connect antigravity-cli`. The `agy` CLI keeps its own config under `~/.gemini/`, separate from the Antigravity IDE above. Pass `--with-hooks` for native auto-capture via `~/.gemini/config/hooks.json`. |
| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` writes the user-level config. Workspace overrides go in `.kiro/settings/mcp.json` next to your code. |
-| **Warp** | `~/.warp/.mcp.json` | `agentmemory connect warp` writes the standard `mcpServers` block. Warp also auto-discovers skills from `.claude/skills/` — once the Claude Code plugin is installed the 8 agentmemory skills (`remember`, `recall`, `recap`, `handoff`, `forget`, `commit-context`, `commit-history`, `session-history`) appear natively in Warp's slash-command palette. |
+| **Warp** | `~/.warp/.mcp.json` | `agentmemory connect warp` writes the standard `mcpServers` block. Warp also auto-discovers skills from `.claude/skills/`; once the Claude Code plugin is installed the 8 agentmemory skills (`remember`, `recall`, `recap`, `handoff`, `forget`, `commit-context`, `commit-history`, `session-history`) appear natively in Warp's slash-command palette. |
| **Cline (CLI)** | `~/.cline/mcp.json` | `agentmemory connect cline` writes the standard `mcpServers` block. VS Code extension users: paste the same block via Cline Settings → MCP Servers → Edit JSON. |
-| **Continue.dev** | `~/.continue/config.yaml` (preferred) or `config.json` (legacy) | `agentmemory connect continue` creates `config.yaml` from scratch when neither exists, or modifies existing `config.json`. **If you already have `config.yaml`** the adapter prints the exact block to paste under `mcpServers:` — it won't silently rewrite your yaml because preserving comments and anchors safely needs a YAML parser the package doesn't ship. Continue uses array form (not object) for `mcpServers`. |
+| **Continue.dev** | `~/.continue/config.yaml` (preferred) or `config.json` (legacy) | `agentmemory connect continue` creates `config.yaml` from scratch when neither exists, or modifies existing `config.json`. **If you already have `config.yaml`** the adapter prints the exact block to paste under `mcpServers:`; it won't silently rewrite your yaml because preserving comments and anchors safely needs a YAML parser the package doesn't ship. Continue uses array form (not object) for `mcpServers`. |
| **Zed** | `~/.config/zed/settings.json` | `agentmemory connect zed` writes under `context_servers` (Zed's key, NOT `mcpServers`). Remote MCP servers can be wired via `{"url": "..."}` instead. |
| **Droid (Factory.ai)** | `~/.factory/mcp.json` | `agentmemory connect droid` writes the standard `mcpServers` block. Project-scoped overrides go in `/.factory/mcp.json`. Pass `--with-hooks` for native auto-capture. |
-| **Goose** | Goose MCP settings UI | Same `mcpServers` block — use `goose configure` → Add Extension → MCP. Direct YAML edit at `~/.config/goose/config.yaml` is supported but the schema uses `extensions:` + `cmd` (not `mcpServers:` + `command`). |
+| **DeepSeek Harness** | `$DSH_HOME/cordis.patch.yml` | `agentmemory connect dsh` appends an `@deepseek-ai/dsh-mcp-client` row to the home-level patch layer every Harness profile loads; tools register as `mcp__agentmemory__*`. Pass `--with-hooks` to also wire auto-capture: the bundled Claude Code hook scripts run through Harness's first-party `@deepseek-ai/dsh-hooks-claude-code` bridge (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop) via a manifest written to `$DSH_HOME/agentmemory.hooks.json`. Defaults to `~/.dsh` when `DSH_HOME` is unset. |
+| **Goose** | Goose MCP settings UI | Same `mcpServers` block; use `goose configure` → Add Extension → MCP. Direct YAML edit at `~/.config/goose/config.yaml` is supported but the schema uses `extensions:` + `cmd` (not `mcpServers:` + `command`). |
| **Aider** | n/a | Talk to the REST API directly: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. |
| **Any agent (32+)** | n/a | `npx skillkit install agentmemory` auto-detects the host and merges. |
@@ -687,7 +720,7 @@ The agentmemory entry is the **same MCP server block** across every host that us
### Programmatic access (Python / Rust / Node)
-agentmemory registers its core operations as iii functions (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`). Any language with an iii SDK can call them directly over `ws://localhost:49134` — no separate REST client per language.
+agentmemory registers its core operations as iii functions (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`). Any language with an iii SDK can call them directly over `ws://localhost:49134`, with no separate REST client per language.
```bash
pip install iii-sdk # Python
@@ -718,7 +751,7 @@ npm install && npm run build && npm start
This starts agentmemory with a local `iii-engine` if `iii` is already installed, or falls back to Docker Compose if Docker is available. REST, streams, and the viewer bind to `127.0.0.1` by default.
-Install `iii-engine` manually. **agentmemory currently pins `iii-engine` to `v0.11.2`** — `v0.11.6` introduces a new sandbox-everything-via-`iii worker add` model that agentmemory hasn't been refactored for yet. Pin lifts once the refactor lands. Override with `AGENTMEMORY_III_VERSION=` if you've migrated to the sandbox model manually.
+Install `iii-engine` manually. **agentmemory currently pins `iii-engine` to `v0.11.2`**. `v0.11.6` introduces a new sandbox-everything-via-`iii worker add` model that agentmemory hasn't been refactored for yet. Pin lifts once the refactor lands. Override with `AGENTMEMORY_III_VERSION=` if you've migrated to the sandbox model manually.
- **macOS arm64:** `mkdir -p ~/.local/bin && curl -fsSL https://github.com/iii-hq/iii/releases/download/iii/v0.11.2/iii-aarch64-apple-darwin.tar.gz | tar -xz -C ~/.local/bin && chmod +x ~/.local/bin/iii`
- **macOS x64:** swap `aarch64-apple-darwin` for `x86_64-apple-darwin`
@@ -730,9 +763,9 @@ Or use Docker (the bundled `docker-compose.yml` pulls `iiidev/iii:0.11.2`). Full
### Windows
-agentmemory runs on Windows 10/11, but the Node.js package alone isn't enough — you also need the `iii-engine` runtime (a separate native binary) as a background process. The official upstream installer is a `sh` script and there is no PowerShell installer or scoop/winget package today, so Windows users have two paths:
+agentmemory runs on Windows 10/11, but the Node.js package alone isn't enough; you also need the `iii-engine` runtime (a separate native binary) as a background process. The official upstream installer is a `sh` script and there is no PowerShell installer or scoop/winget package today, so Windows users have two paths:
-**Option A — Prebuilt Windows binary (recommended):**
+**Option A: prebuilt Windows binary (recommended)**
```powershell
# 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 in your browser
@@ -751,7 +784,7 @@ iii --version
npx -y @agentmemory/agentmemory
```
-**Option B — Docker Desktop:**
+**Option B: Docker Desktop**
```powershell
# 1. Install Docker Desktop for Windows
@@ -760,7 +793,7 @@ npx -y @agentmemory/agentmemory
npx -y @agentmemory/agentmemory
```
-**Option C — standalone MCP only (no engine):** if you only need the MCP tools for your agent and don't need the REST API, viewer, or cron jobs, skip the engine entirely:
+**Option C: standalone MCP only (no engine).** If you only need the MCP tools for your agent and don't need the REST API, viewer, or cron jobs, skip the engine entirely:
```powershell
npx -y @agentmemory/agentmemory mcp
@@ -772,12 +805,12 @@ npx -y @agentmemory/mcp
| Symptom | Fix |
|---|---|
-| `iii-engine process started` then `did not become ready within 15s` | Engine crashed on startup — re-run with `--verbose`, check stderr |
+| `iii-engine process started` then `did not become ready within 15s` | Engine crashed on startup; re-run with `--verbose`, check stderr |
| `Could not start iii-engine` | Neither `iii.exe` nor Docker is installed. See Option A or B above |
| Port conflict | `netstat -ano \| findstr :3111` to see what's bound, then kill it or use `--port ` |
| Docker fallback skipped even though Docker is installed | Make sure Docker Desktop is actually running (system tray icon) |
-> Note: the iii **engine** is a prebuilt binary, not a cargo crate — don't try to `cargo install` it. (The iii **SDKs** are published on crates.io, npm, and PyPI, but agentmemory doesn't need them.) Supported engine install methods, all pinned to v0.11.2: the prebuilt v0.11.2 binary above, the upstream sh install script **with the version pin** `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux), and the Docker image `iiidev/iii:0.11.2`. A bare `install.sh | sh` installs the **latest** engine, which agentmemory does not support — always pass `VERSION=0.11.2`. Easiest of all: just run `npx @agentmemory/agentmemory`, which fetches the pinned engine into `~/.agentmemory/bin` for you.
+> Note: the iii **engine** is a prebuilt binary, not a cargo crate, so don't try to `cargo install` it. (The iii **SDKs** are published on crates.io, npm, and PyPI, but agentmemory doesn't need them.) Supported engine install methods, all pinned to v0.11.2: the prebuilt v0.11.2 binary above, the upstream sh install script **with the version pin** `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux), and the Docker image `iiidev/iii:0.11.2`. A bare `install.sh | sh` installs the **latest** engine, which agentmemory does not support; always pass `VERSION=0.11.2`. Easiest of all: just run `npx @agentmemory/agentmemory`, which fetches the pinned engine into `~/.agentmemory/bin` for you.
---
@@ -786,7 +819,7 @@ npx -y @agentmemory/mcp
One-click templates for managed hosts. Each one ships a self-contained
Dockerfile that pulls `@agentmemory/agentmemory` from npm and copies
the iii engine binary in from the official `iiidev/iii` Docker Hub
-image — no pre-built agentmemory image required. Persistent storage
+image; no pre-built agentmemory image required. Persistent storage
mounts at `/data`; the first-boot entrypoint overwrites the
npm-bundled iii config (which binds `127.0.0.1`) with a deploy-tuned
one that binds `0.0.0.0` and uses absolute `/data` paths, generates
@@ -803,25 +836,25 @@ Render's one-click deploy button requires `render.yaml` at the repository root,
Full setup details (HMAC capture, viewer SSH tunnel, rotation, backup,
cost floors) live in [`deploy/`](./deploy/README.md):
-- [`deploy/fly`](./deploy/fly/README.md) — single machine with
+- [`deploy/fly`](./deploy/fly/README.md): single machine with
`auto_stop_machines = "stop"`; cheapest idle.
-- [`deploy/railway`](./deploy/railway/README.md) — Hobby plan flat fee,
+- [`deploy/railway`](./deploy/railway/README.md): Hobby plan flat fee,
volume in the dashboard.
-- [`deploy/render`](./deploy/render/README.md) — Blueprint flow,
+- [`deploy/render`](./deploy/render/README.md): Blueprint flow,
automatic disk snapshots on paid plans.
-- [`deploy/coolify`](./deploy/coolify/README.md) — self-hosted on your
+- [`deploy/coolify`](./deploy/coolify/README.md): self-hosted on your
own VPS via [Coolify](https://coolify.io/self-hosted); same Docker
Compose stack, you own the host and the data.
Only port `3111` is published. The viewer on `3113` stays bound to
-loopback inside the container — every template's README documents the
+loopback inside the container; every template's README documents the
SSH-tunnel pattern for reaching it.
---
-Every coding agent forgets everything when the session ends. You waste the first 5 minutes of every session re-explaining your stack. agentmemory runs in the background and eliminates that entirely.
+Every coding agent forgets everything when the session ends, and each new session starts with you re-explaining your stack. agentmemory runs in the background and removes that step.
```text
Session 1: "Add auth to the API"
@@ -839,7 +872,7 @@ Session 2: "Now add rate limiting"
### vs built-in agent memory
-Every AI coding agent ships with built-in memory — Claude Code has `MEMORY.md`, Cursor has notepads, Cline has memory bank. These work like sticky notes. agentmemory is the searchable database behind the sticky notes.
+Every AI coding agent ships with built-in memory: Claude Code has `MEMORY.md`, Cursor has notepads, Cline has memory bank. These work like sticky notes. agentmemory is the searchable database behind the sticky notes.
| | Built-in (CLAUDE.md) | agentmemory |
|---|---|---|
@@ -879,7 +912,7 @@ SessionStart hook fires
### 4-Tier Memory Consolidation
-Inspired by how human brains process memory — not unlike sleep consolidation.
+Modeled on how human brains process memory, including sleep consolidation.
| Tier | What | Analogy |
|------|------|---------|
@@ -908,9 +941,13 @@ Memories decay over time (Ebbinghaus curve). Frequently accessed memories streng
| Capability | Description |
|---|---|
-| **Automatic capture** | Every tool use recorded via hooks — zero manual effort |
+| **Automatic capture** | Every tool use recorded via hooks, no manual effort |
| **Semantic search** | BM25 + vector + knowledge graph with RRF fusion |
| **Memory evolution** | Versioning, supersession, relationship graphs |
+| **Recall hygiene** | Superseded memory versions leave the search indexes; the version chain in KV keeps full history |
+| **Near-duplicate hints** | Saves report an advisory `similarTo` match when new content closely resembles an existing memory |
+| **Per-agent scoping** | `agentId` threads through save and recall across REST, MCP, and the search index, in shared or isolated mode |
+| **Write-time provenance** | Every observation and memory carries an immutable origin channel (user, agent, tool, import, or shared) stamped at capture, save, and import |
| **Auto-forgetting** | TTL expiry, contradiction detection, importance eviction |
| **Privacy first** | API keys, secrets, `` tags stripped before storage |
| **Self-healing** | Circuit breaker, provider fallback chain, health monitoring |
@@ -934,6 +971,8 @@ Triple-stream retrieval combining three signals:
Fused with Reciprocal Rank Fusion (RRF, k=60) and session-diversified (max 3 results per session).
+Hybrid ranking applies to the primary recall path, not just `smart-search`: `mem::search` (behind `memory_recall`) ranks through the same BM25 + vector + graph fusion once the vector index is populated. Lesson recall runs on a dedicated in-memory BM25 index instead of scanning the whole corpus per query. Superseded memory versions are excluded from every recall path; the version chain keeps their history.
+
BM25 tokenizes Greek, Cyrillic, Hebrew, Arabic, and accented Latin out of the box. For Chinese / Japanese / Korean memories, install the optional segmenters (`npm install @node-rs/jieba tiny-segmenter`) to split CJK runs into word-level tokens; without them, agentmemory soft-falls to whole-run tokenization and prints a one-time hint on stderr.
### Embedding providers
@@ -957,33 +996,38 @@ npm install @huggingface/transformers
-54 tools, 6 resources, 3 prompts, and 15 skills, the most comprehensive MCP memory toolkit for any agent.
+54 tools, 6 resources, 3 prompts, and 15 skills.
-> **MCP shim vs full server:** the published `@agentmemory/mcp` package is a thin shim. It exposes the full 54-tool surface **only when it can reach a running agentmemory server** via `AGENTMEMORY_URL` (proxy mode). With no server reachable, the shim falls back to a 7-tool local set (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). The `AGENTMEMORY_TOOLS=core|all` env var is a *server-side* flag — setting it in the shim's `env` block has no effect. If you see only 7 tools in Cursor / OpenCode / Gemini CLI, start `npx @agentmemory/agentmemory` (or the Docker stack) and set `AGENTMEMORY_URL=http://localhost:3111`.
+> **MCP shim vs full server:** the published `@agentmemory/mcp` package is a thin shim. It exposes the full 54-tool surface **only when it can reach a running agentmemory server** via `AGENTMEMORY_URL` (proxy mode). With no server reachable, the shim falls back to a 7-tool local set (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). The `AGENTMEMORY_TOOLS=core|all` env var is a *server-side* flag; setting it in the shim's `env` block has no effect. If you see only 7 tools in Cursor / OpenCode / Gemini CLI, start `npx @agentmemory/agentmemory` (or the Docker stack) and set `AGENTMEMORY_URL=http://localhost:3111`.
### 54 Tools
+Three tool surfaces, smallest to largest: `AGENTMEMORY_TOOLS=core` trims visibility to 8 essentials (`memory_save`, `memory_recall`, `memory_consolidate`, `memory_smart_search`, `memory_sessions`, `memory_diagnose`, `memory_lesson_save`, `memory_reflect`); the base set below is the registry's 14 foundational tools; the default (`AGENTMEMORY_TOOLS=all`) exposes all 54.
+
-Core tools (always available)
+Base tools (14)
| Tool | Description |
|------|-------------|
| `memory_recall` | Search past observations |
| `memory_compress_file` | Compress markdown files while preserving structure |
| `memory_save` | Save an insight, decision, or pattern |
-| `memory_patterns` | Detect recurring patterns |
-| `memory_smart_search` | Hybrid semantic + keyword search |
| `memory_file_history` | Past observations about specific files |
+| `memory_patterns` | Detect recurring patterns |
| `memory_sessions` | List recent sessions |
+| `memory_smart_search` | Hybrid semantic + keyword search |
+| `memory_vision_search` | Search image observations |
| `memory_timeline` | Chronological observations |
| `memory_profile` | Project profile (concepts, files, patterns) |
| `memory_export` | Export all memory data |
| `memory_relations` | Query relationship graph |
+| `memory_commit_lookup` | Sessions behind a git commit |
+| `memory_commits` | Commits recorded for a session |
-Extended tools (54 total — set AGENTMEMORY_TOOLS=all)
+Extended tools (54 total, the default surface)
| Tool | Description |
|------|-------------|
@@ -1021,14 +1065,16 @@ npm install @huggingface/transformers
-### 6 Resources · 3 Prompts · 4 Skills
+### 6 Resources · 3 Prompts · 15 Skills
| Type | Name | Description |
|------|------|-------------|
| Resource | `agentmemory://status` | Health, session count, memory count |
| Resource | `agentmemory://project/{name}/profile` | Per-project intelligence |
+| Resource | `agentmemory://project/{name}/recent` | Recent observations for a project |
| Resource | `agentmemory://memories/latest` | Latest 10 active memories |
| Resource | `agentmemory://graph/stats` | Knowledge graph statistics |
+| Resource | `agentmemory://team/{id}/profile` | Shared team profile |
| Prompt | `recall_context` | Search + return context messages |
| Prompt | `session_handoff` | Handoff data between agents |
| Prompt | `detect_patterns` | Analyze recurring patterns |
@@ -1037,9 +1083,11 @@ npm install @huggingface/transformers
| Skill | `/session-history` | Recent session summaries |
| Skill | `/forget` | Delete observations/sessions |
+The table shows the four core skills. The full set is 8 invocable skills plus 7 reference skills; see the Native skills section above.
+
### Standalone MCP
-Run without the full server — for any MCP client. Either of these works:
+Run without the full server, for any MCP client. Either of these works:
```bash
npx -y @agentmemory/agentmemory mcp # canonical (always available)
@@ -1090,7 +1138,7 @@ cp plugin/opencode/commands/*.md ~/.config/opencode/commands/
-Auto-starts on port `3113`. Live observation stream, session explorer, memory browser, knowledge graph visualization, and health dashboard.
+Auto-starts on port `3113`. Live observation stream with a stream status indicator, a two-pane session explorer (list beside a sticky detail panel on wide screens), memory and lesson rows that expand to the full stored record including raw JSON and origin provenance, a knowledge graph that clusters nodes by type while relations are sparse, session replay, and a health dashboard.
```bash
open http://localhost:3113
@@ -1102,19 +1150,19 @@ The viewer server binds to `127.0.0.1` by default. The REST-served `/agentmemory
-The viewer at `:3113` shows what your agent **remembered**. The [iii console](https://iii.dev/docs/console) shows what your agent **did** — every memory op as an OpenTelemetry trace, every KV entry editable, every function invocable, every stream tappable. Two windows on the same memory: one product-shaped, one engine-shaped.
+The viewer at `:3113` shows what your agent **remembered**. The [iii console](https://iii.dev/docs/console) shows what your agent **did**: every memory op as an OpenTelemetry trace, every KV entry editable, every function invocable, every stream tappable. Two windows on the same memory: one product-shaped, one engine-shaped.
Watch a `memory_smart_search` fire and see the BM25 scan → embedding lookup → RRF fusion → reranker as a waterfall. Edit a stuck consolidation timer in the KV browser. Replay a `PostToolUse` hook with a tweaked payload. Pin the WebSocket stream and watch observations land live.
-agentmemory ships this for free because every function call and trigger fires through iii — nothing custom, nothing to instrument.
+agentmemory ships this for free because every function call and trigger fires through iii; nothing custom, nothing to instrument.
-
+
- Workers page: every connected worker — including agentmemory itself — with PID, function count, runtime, and last-seen.
+ Workers page: every connected worker, including agentmemory itself, with PID, function count, runtime, and last-seen.
-**Already installed.** The console ships with `iii` — no separate installer.
+**Already installed.** The console ships with `iii`; no separate installer.
**Launch alongside agentmemory:**
@@ -1139,15 +1187,15 @@ iii console --port 3114 \
| Page | Use it to |
|------|-----------|
-| **Workers** | See every connected worker and its live metrics — including the agentmemory worker itself. |
-| **Functions** | Invoke any of agentmemory's functions directly with a JSON payload — handy for testing `memory.recall`, `memory.consolidate`, `graph.query` without wiring a client. |
-| **Triggers** | Replay HTTP, cron, event, and state triggers — fire the consolidation cron manually, retry an HTTP route, emit a state change. |
-| **States** | KV browser with full CRUD — sessions, memory slots, lifecycle timers, embeddings index — edit values in place. |
+| **Workers** | See every connected worker and its live metrics, including the agentmemory worker itself. |
+| **Functions** | Invoke any of agentmemory's functions directly with a JSON payload; handy for testing `memory.recall`, `memory.consolidate`, `graph.query` without wiring a client. |
+| **Triggers** | Replay HTTP, cron, event, and state triggers: fire the consolidation cron manually, retry an HTTP route, emit a state change. |
+| **States** | KV browser with full CRUD over sessions, memory slots, lifecycle timers, and the embeddings index; edit values in place. |
| **Streams** | Live WebSocket monitor for memory writes, hook events, and observation updates as they flow through iii streams. |
| **Queues** | Durable queue topics + dead-letter management. Replay or drop failed embedding / compression jobs. |
| **Traces** | OpenTelemetry waterfall / flame / service-breakdown views. Filter by `trace_id` to see exactly which functions, DB calls, and embedding requests a single `memory.search` produced. |
| **Logs** | Structured OTEL logs filtered and correlated to trace/span IDs. |
-| **Config** | Runtime configuration — see exactly which workers, providers, and ports your engine is running with. |
+| **Config** | Runtime configuration: see exactly which workers, providers, and ports your engine is running with. |
| **Flow** | (Optional, `--enable-flow`) Interactive architecture graph of every worker, trigger, and stream. |
@@ -1158,17 +1206,17 @@ iii console --port 3114 \
**Traces are already on:**
-`iii-config.yaml` ships with the `iii-observability` worker enabled (`exporter: memory`, `sampling_ratio: 1.0`, metrics + logs). No extra config needed — the moment agentmemory starts, every memory operation emits a trace span and a structured log the console can read.
+`iii-config.yaml` ships with the `iii-observability` worker enabled (`exporter: memory`, `sampling_ratio: 1.0`, metrics + logs). No extra config needed; the moment agentmemory starts, every memory operation emits a trace span and a structured log the console can read.
If you want to export to Jaeger/Honeycomb/Grafana Tempo instead, change `exporter: memory` to `exporter: otlp` and set the collector endpoint per iii's observability docs.
-> **Heads-up:** no auth is enforced on the console itself — keep it bound to `127.0.0.1` (the default) and never expose it publicly.
+> **Heads-up:** no auth is enforced on the console itself; keep it bound to `127.0.0.1` (the default) and never expose it publicly.
---
-agentmemory is **already a running [iii](https://iii.dev) instance**. Three primitives — worker, function, trigger — compose the runtime; KV state, streams, and OTEL traces come from iii-state, iii-stream, and iii-observability workers that ship with iii. You didn't install Postgres, Redis, Express, pm2, or Prometheus, because iii replaces them.
+agentmemory is **already a running [iii](https://iii.dev) instance**. Three primitives (worker, function, trigger) compose the runtime; KV state, streams, and OTEL traces come from iii-state, iii-stream, and iii-observability workers that ship with iii. You didn't install Postgres, Redis, Express, pm2, or Prometheus, because iii replaces them.
That means one more command extends agentmemory with an entire new capability.
@@ -1184,19 +1232,19 @@ iii worker add iii-database # swap in a SQL-backed state adapter
iii worker add mcp # generic MCP host alongside the agentmemory MCP
```
-Each `iii worker add` registers new functions and triggers into the same engine agentmemory is already running on. The viewer and console pick them up immediately — no reload, no new integration, no new container.
+Each `iii worker add` registers new functions and triggers into the same engine agentmemory is already running on. The viewer and console pick them up immediately: no reload, no new integration, no new container.
| `iii worker add` | What you get on top of agentmemory |
|---|---|
| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | Multi-instance memory: every `remember` fans out, every `search` reads the union |
-| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Scheduled lifecycle — nightly consolidation, weekly snapshots, decay on a fixed clock |
+| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Scheduled lifecycle: nightly consolidation, weekly snapshots, decay on a fixed clock |
| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | Durable retries: failed embedding + compression jobs survive restart, no lost observations |
-| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | OTEL traces, metrics, logs on every function — wired in `iii-config.yaml` from day one |
+| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | OTEL traces, metrics, logs on every function, wired in `iii-config.yaml` from day one |
| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | Code that came out of `memory_recall` runs inside a throwaway VM, not your shell |
| [`iii-database`](https://workers.iii.dev/workers/iii-database) | SQL-backed state adapter when you outgrow the in-memory KV defaults |
| [`mcp`](https://workers.iii.dev/workers/mcp) | Stand up extra MCP servers next to agentmemory's, share the same engine |
-Full registry: [workers.iii.dev](https://workers.iii.dev). Every worker there composes through the same primitives agentmemory uses — and the agentmemory you already have is one of them.
+Full registry: [workers.iii.dev](https://workers.iii.dev). Every worker there composes through the same primitives agentmemory uses, and the agentmemory you already have is one of them.
### What iii replaces
@@ -1209,7 +1257,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 ` |
-**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.
+**182 source files · ~41,600 LOC · 1,619 tests · 264 functions · 50 KV scopes**, all on three primitives. No `agentmemory plugin install`. The plugin system is iii itself.
---
@@ -1226,18 +1274,18 @@ agentmemory auto-detects from your environment. By default, no LLM calls are mad
| MiniMax | `MINIMAX_API_KEY` | Anthropic-compatible |
| Gemini | `GEMINI_API_KEY` | Also enables embeddings |
| OpenRouter | `OPENROUTER_API_KEY` | Any model |
-| OpenAI API | `OPENAI_API_KEY` | Default `gpt-4o-mini`, override with `OPENAI_MODEL` |
+| OpenAI API | `OPENAI_API_KEY` | Default `gpt-5.6-luna`, override with `OPENAI_MODEL` |
| **Local (Ollama / LM Studio / vLLM / llama.cpp)** | `OPENAI_API_KEY=local` + `OPENAI_BASE_URL=http://localhost:11434/v1` (Ollama) or `http://localhost:1234/v1` (LM Studio) + `OPENAI_MODEL=` | Anything OpenAI-API-compatible. Zero cost, runs on your hardware. See [Local models](#local-models-ollama--lm-studio--vllm) below. |
-| Claude subscription fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Opt-in only. Spawns `@anthropic-ai/claude-agent-sdk` sessions — used to cause unbounded Stop-hook recursion so it is no longer the default. |
+| Claude subscription fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Opt-in only. Spawns `@anthropic-ai/claude-agent-sdk` sessions; it used to cause unbounded Stop-hook recursion, so it is no longer the default. |
### Local models (Ollama / LM Studio / vLLM)
-agentmemory talks to any OpenAI-API-compatible server, so anything that exposes `/v1/chat/completions` works without code changes. No paid keys, no cloud, no rate limits — runs entirely on your hardware.
+agentmemory talks to any OpenAI-API-compatible server, so anything that exposes `/v1/chat/completions` works without code changes. No paid keys, no cloud, no rate limits; runs entirely on your hardware.
**Ollama** (default port `11434`):
```bash
-ollama pull qwen2.5-coder:7b # or llama3.2:3b, mistral:7b, etc.
+ollama pull qwen3:8b # or qwen3:4b, gpt-oss:20b, qwen3-coder:30b, etc.
ollama serve
```
@@ -1245,34 +1293,37 @@ ollama serve
# ~/.agentmemory/.env
OPENAI_API_KEY=ollama # any non-empty string; Ollama ignores it
OPENAI_BASE_URL=http://localhost:11434/v1
-OPENAI_MODEL=qwen2.5-coder:7b
+OPENAI_MODEL=qwen3:8b
```
**LM Studio** (default port `1234`):
-Open LM Studio → Local Server tab → Start Server. Pick any chat model from the picker (Qwen 2.5 Coder, Llama 3.2, DeepSeek, etc.).
+Open LM Studio → Local Server tab → Start Server. Pick any chat model from the picker (Qwen 3, gpt-oss, DeepSeek R1, etc.).
```env
# ~/.agentmemory/.env
OPENAI_API_KEY=lmstudio # any non-empty string; LM Studio ignores it
OPENAI_BASE_URL=http://localhost:1234/v1
-OPENAI_MODEL=qwen2.5-coder-7b-instruct # match the model name from LM Studio
+OPENAI_MODEL=qwen3-8b # match the model name from LM Studio
```
-**vLLM / llama.cpp / Text Generation Inference**: same shape — point `OPENAI_BASE_URL` at whatever URL your server exposes, set `OPENAI_MODEL` to a name your server will accept.
+**vLLM / llama.cpp / Text Generation Inference**: same shape. Point `OPENAI_BASE_URL` at whatever URL your server exposes and set `OPENAI_MODEL` to a name your server will accept.
**Model picks for memory work**: compression and summarization are short tasks (<2K tokens in, <500 tokens out) where a 7B instruct model is plenty. Recommendations:
| Model | Size | Why |
|-------|------|-----|
-| `qwen2.5-coder:7b` | ~4.7 GB | Best at code-shaped sessions; trained on programming + tool-use traces |
-| `llama3.2:3b` | ~2 GB | Smallest sane option — fine for compression, weaker for graph extraction |
-| `mistral:7b-instruct` | ~4.4 GB | Good general-purpose baseline if you don't want code-specific |
-| `deepseek-r1:7b` | ~4.7 GB | Reasoning-tier quality at 7B; slower but cleaner extractions |
+| `qwen3:8b` | ~5.2 GB | Balanced default on a 16 GB machine; strong at extraction and tool-shaped text |
+| `qwen3:4b` | ~2.6 GB | Smallest sane option; fine for compression, weaker for graph extraction |
+| `qwen3-coder:30b` | ~19 GB | Best local pick for code-shaped sessions (30B MoE, 3.3B active) on 24-32 GB hardware |
+| `gpt-oss:20b` | ~14 GB | Strong general model that fits 16 GB RAM |
+| `deepseek-r1:8b` | ~5.2 GB | Reasoning distill; slower but cleaner extractions |
+
+Qwen 3 models think by default and can burn the whole token budget on reasoning before any output. Set `AGENTMEMORY_LLM_NOTHINK=1` to append `/no_think` to graph-extraction prompts, and raise `MAX_TOKENS` (16384 works) if extractions come back empty.
Reasoning-class models (`o1`-style with `` blocks) can return empty `content` with a `reasoning` field your local server may not surface. If extractions come back blank, switch to a non-reasoning model first. The `OPENAI_REASONING_EFFORT=none` env can also disable thinking on Ollama Cloud thinking models that mirror the OpenAI reasoning schema.
-Local embeddings ship out of the box via `@huggingface/transformers` — `EMBEDDING_PROVIDER=local` (default) gives you `Xenova/all-MiniLM-L6-v2` (384-dim) entirely on-device. No extra config needed.
+Local embeddings ship out of the box via `@huggingface/transformers`: `EMBEDDING_PROVIDER=local` (default) gives you `Xenova/all-MiniLM-L6-v2` (384-dim) entirely on-device. No extra config needed.
### Cost-aware model selection
@@ -1280,18 +1331,20 @@ Background compression runs on every observation, so model choice meaningfully c
| Tier | Model | Input / 1M | Output / 1M | Cost for the captured 35h | Notes |
|------|-------|------------|-------------|---------------------------|-------|
+| Recommended | `deepseek/deepseek-v4-flash-0731` | $0.07 | $0.14 | ~$0.07 (est.) | Latest DeepSeek; cheapest recommended pick for compression workloads. |
| Recommended | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | Solid compression + summarization quality at ~10× lower cost than Sonnet. |
-| Recommended | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | Older but still fine for compression-only workloads. |
| Recommended | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | Strong code reasoning if your sessions are heavily code-shaped. |
-| Premium | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | High quality but expensive for always-on background work. |
-| Premium | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | Similar tier to Sonnet. |
-| Avoid | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | Reasoning-class model; massive overspend for compression. |
+| Premium | `anthropic/claude-sonnet-5` | $3.00 | $15.00 | ~$5.02 (est.) | Same list price as the measured Sonnet 4.6 run; $2/$10 intro pricing through 2026-08-31. |
+| Premium | `openai/gpt-5.6-sol` | $5.00 | $30.00 | ~$9 (est.) | Flagship tier; expensive for always-on background work. |
+| Avoid | `anthropic/claude-opus-5` | $5.00 | $25.00 | ~$8.40 (est.) | Flagship-class model; overspend for compression. |
+
+Measured rows come from the captured run; (est.) rows scale the same token mix by each model's list price.
agentmemory prints a runtime warning when `OPENROUTER_MODEL` matches a premium-tier pattern. Set `AGENTMEMORY_SUPPRESS_COST_WARNING=1` to silence once you've made an informed choice.
-Quality vs cost tradeoff for memory work: compression is a summarization task with relatively loose quality bars (the agent re-reads the summary, not the user). DeepSeek-V4-Pro / Qwen3-Coder land within rounding error of Sonnet on this task while costing ~10× less. Save the premium-tier models for queries you read directly.
+Quality vs cost tradeoff for memory work: compression is a summarization task with relatively loose quality bars (the agent re-reads the summary, not the user). DeepSeek V4 Flash / V4 Pro / Qwen3-Coder land within rounding error of Sonnet on this task while costing 10-70× less. Save the premium-tier models for queries you read directly.
-Sources: [OpenRouter pricing for Sonnet 4.6](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing), [DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro), [DeepSeek pricing notes](https://api-docs.deepseek.com/quick_start/pricing/).
+Sources: [OpenRouter pricing for Claude Sonnet 5](https://openrouter.ai/anthropic/claude-sonnet-5), [DeepSeek V4 Flash](https://openrouter.ai/deepseek/deepseek-v4-flash-0731), [DeepSeek pricing notes](https://api-docs.deepseek.com/quick_start/pricing/).
### Multi-agent memory (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`)
@@ -1315,7 +1368,7 @@ What gets tagged when `AGENT_ID` is set: `Session.agentId`, `RawObservation.agen
What gets filtered in isolated mode: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Each endpoint accepts `?agentId=` to override per-request, and `?agentId=*` to opt out of the env scope entirely. `/memories` also accepts `?includeOrphans=true` to surface pre-AGENT_ID memories whose `agentId` is undefined.
-Per-call override at the SDK / REST layer: every mutating endpoint (`/session/start`, `/remember`) accepts an `agentId` field in the request body that wins over the env. Useful for runtimes routing many roles through one server process.
+Per-call override at the SDK / REST layer: every mutating endpoint (`/session/start`, `/remember`) accepts an `agentId` field in the request body that wins over the env. Useful for runtimes routing many roles through one server process. The MCP `memory_save` tool exposes the same `agentId` field, the standalone stdio server forwards both `agentId` and `project`, and saved memories carry `agentId` into the search index, so agent-scoped search covers memories as well as observations.
When `AGENT_ID` is unset, memory remains unscoped (legacy behavior, no tags, no filters).
@@ -1328,7 +1381,7 @@ agentmemory + iii-engine bind four ports by default. If a restart fails with `po
| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` |
| `3112` | iii-engine | Internal streams worker (consumed by agentmemory + viewer) | `III_STREAMS_PORT` |
| `3113` | agentmemory | Real-time viewer (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` |
-| `49134` | iii-engine | WebSocket — workers register here, OTel telemetry flows over it | `III_ENGINE_URL` (full URL, default `ws://localhost:49134`) |
+| `49134` | iii-engine | WebSocket; workers register here, OTel telemetry flows over it | `III_ENGINE_URL` (full URL, default `ws://localhost:49134`) |
Stale-process cleanup when ports stay bound after a crashed run:
@@ -1343,7 +1396,7 @@ netstat -ano | findstr ":3111 :3112 :3113 :49134"
taskkill /F /PID
```
-`agentmemory stop` reaps both the worker and the engine pidfile cleanly on graceful shutdown. The manual cleanup above is only for the post-crash case where neither pidfile is left behind.
+`agentmemory stop` reaps both the worker and the engine pidfile cleanly on graceful shutdown. In Docker mode it tears down only agentmemory's own compose services and reaps the native worker before the Docker teardown; the CLI also refuses to adopt or signal Docker or VM port holders (Docker backend, vpnkit, colima) as the native engine unless `--force` is passed. The manual cleanup above is only for the post-crash case where neither pidfile is left behind.
### Config File
@@ -1393,7 +1446,7 @@ Create `~/.agentmemory/.env`:
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
@@ -1479,6 +1532,10 @@ Create `~/.agentmemory/.env`:
# Observations are still captured via
# PostToolUse regardless of this flag.
# GRAPH_EXTRACTION_ENABLED=false
+# AGENTMEMORY_LLM_NOTHINK=1 # Local reasoning models only: ask the
+ # model to skip its hidden thinking pass
+ # during graph extraction. Faster runs;
+ # relation quality can drop slightly.
# CONSOLIDATION_ENABLED=false # on by default when an LLM provider is configured
# LESSON_DECAY_ENABLED=true
# OBSIDIAN_AUTO_EXPORT=false
@@ -1491,7 +1548,7 @@ Create `~/.agentmemory/.env`:
# USER_ID=
# TEAM_MODE=private
-# Tool visibility: "core" (8 tools, lean fallback) or "all" (54 tools)
+# Tool visibility: "all" (54 tools, default) or "core" (8 tools, lean)
# AGENTMEMORY_TOOLS=core
```
@@ -1533,7 +1590,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,596+ tests
+npm test # 1,619 tests
npm run test:integration # API tests (requires running services)
```
diff --git a/READMEs/README.de-DE.md b/READMEs/README.de-DE.md
index 332519b48..906780a49 100644
--- a/READMEs/README.de-DE.md
+++ b/READMEs/README.de-DE.md
@@ -1215,7 +1215,7 @@ CONSOLIDATION_ENABLED=true
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.es-ES.md b/READMEs/README.es-ES.md
index af7e7b355..858bc6adb 100644
--- a/READMEs/README.es-ES.md
+++ b/READMEs/README.es-ES.md
@@ -1208,7 +1208,7 @@ Crea `~/.agentmemory/.env`:
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.fr-FR.md b/READMEs/README.fr-FR.md
index f39522607..22be09378 100644
--- a/READMEs/README.fr-FR.md
+++ b/READMEs/README.fr-FR.md
@@ -1215,7 +1215,7 @@ Créez `~/.agentmemory/.env` :
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.hi-IN.md b/READMEs/README.hi-IN.md
index dc6f8d9f0..ef3ca3787 100644
--- a/READMEs/README.hi-IN.md
+++ b/READMEs/README.hi-IN.md
@@ -1218,7 +1218,7 @@ CONSOLIDATION_ENABLED=true
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.ja-JP.md b/READMEs/README.ja-JP.md
index bca18961e..aad9ac2d4 100644
--- a/READMEs/README.ja-JP.md
+++ b/READMEs/README.ja-JP.md
@@ -1218,7 +1218,7 @@ CONSOLIDATION_ENABLED=true
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.ko-KR.md b/READMEs/README.ko-KR.md
index 7bd900503..962ddea1a 100644
--- a/READMEs/README.ko-KR.md
+++ b/READMEs/README.ko-KR.md
@@ -1199,7 +1199,7 @@ CONSOLIDATION_ENABLED=true
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.pt-BR.md b/READMEs/README.pt-BR.md
index e56fd7993..e8ae60b3e 100644
--- a/READMEs/README.pt-BR.md
+++ b/READMEs/README.pt-BR.md
@@ -1208,7 +1208,7 @@ Crie `~/.agentmemory/.env`:
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.ru-RU.md b/READMEs/README.ru-RU.md
index 0e112f70e..1983de9e9 100644
--- a/READMEs/README.ru-RU.md
+++ b/READMEs/README.ru-RU.md
@@ -1215,7 +1215,7 @@ CONSOLIDATION_ENABLED=true
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.tr-TR.md b/READMEs/README.tr-TR.md
index 4b68acc78..4347d06a2 100644
--- a/READMEs/README.tr-TR.md
+++ b/READMEs/README.tr-TR.md
@@ -1219,7 +1219,7 @@ CONSOLIDATION_ENABLED=true
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.zh-CN.md b/READMEs/README.zh-CN.md
index e7b50d524..26e312840 100644
--- a/READMEs/README.zh-CN.md
+++ b/READMEs/README.zh-CN.md
@@ -1216,7 +1216,7 @@ CONSOLIDATION_ENABLED=true
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/READMEs/README.zh-TW.md b/READMEs/README.zh-TW.md
index ce87e241c..de9ce0f83 100644
--- a/READMEs/README.zh-TW.md
+++ b/READMEs/README.zh-TW.md
@@ -1216,7 +1216,7 @@ CONSOLIDATION_ENABLED=true
# # Auto-detected from `.openai.azure.com` hostname; uses
# # api-key header + api-version query param.
# OPENAI_API_VERSION=2024-08-01-preview # Optional: Azure api-version query param
-# OPENAI_MODEL=gpt-4o-mini # Optional: default model
+# OPENAI_MODEL=gpt-5.6-luna # Optional: default model
# OPENAI_TIMEOUT_MS=60000 # Optional: OpenAI-scoped alias for the outbound fetch
# # timeout. Takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
# # for back-compat with v0.9.17. New configs should
diff --git a/assets/agents/pi.svg b/assets/agents/pi.svg
index 3d40fc0e7..3dd7d06b2 100644
--- a/assets/agents/pi.svg
+++ b/assets/agents/pi.svg
@@ -1,5 +1,6 @@
+
VS COMPETITORS
- Mem0 · Letta · Khoj · Hippo · claude-mem
+ Mem0 · Letta · Zep · TencentDB · more
\ No newline at end of file
diff --git a/assets/tags/light/stat-tests.svg b/assets/tags/light/stat-tests.svg
index b8f386db0..f4675bd97 100644
--- a/assets/tags/light/stat-tests.svg
+++ b/assets/tags/light/stat-tests.svg
@@ -1,5 +1,5 @@
-
+
- 1596+
+ 1648+
TESTS PASSING
diff --git a/assets/tags/section-competitors.svg b/assets/tags/section-competitors.svg
index 90761e861..5a0dfdc05 100644
--- a/assets/tags/section-competitors.svg
+++ b/assets/tags/section-competitors.svg
@@ -12,5 +12,5 @@
VS COMPETITORS
- Mem0 · Letta · Khoj · Hippo · claude-mem
+ Mem0 · Letta · Zep · TencentDB · more
\ No newline at end of file
diff --git a/assets/tags/stat-tests.svg b/assets/tags/stat-tests.svg
index 8a4637dde..a7599939c 100644
--- a/assets/tags/stat-tests.svg
+++ b/assets/tags/stat-tests.svg
@@ -1,5 +1,5 @@
-
+
- 1596+
+ 1648+
TESTS PASSING
diff --git a/benchmark/COMPARISON.md b/benchmark/COMPARISON.md
index 8914c98b6..207ca0f68 100644
--- a/benchmark/COMPARISON.md
+++ b/benchmark/COMPARISON.md
@@ -121,6 +121,22 @@ This isn't a "agentmemory wins everything" page. Different tools solve different
- Multi-agent shared memory as a primary feature
- "Forget by default, earn persistence through use" philosophy
+**Choose TencentDB Agent Memory if you want:**
+- Team-level shared memory: conversations, docs, and code turned into four asset types (Chat Memory, Skill, Wiki, CodeGraph) with team roles and ownership
+- Zero-integration capture via an LLM proxy (point the agent's base URL at it; no hooks or MCP required)
+- CodeGraph impact analysis (symbols, call relationships) alongside memory
+- Note: the proxy sits in front of every model call, deployment is a multi-service Docker stack (Core + Hub + Proxy), the published benchmark is PersonaMem (76%, self-reported), and automated memory routing is still in progress per their README
+
+**Choose Zep / Graphiti if you want:**
+- A temporal knowledge graph: facts carry a time dimension, so "what was true when" is a first-class query
+- The strongest published temporal-query results (LongMemEval 63.8%)
+- Note: graph construction runs in the background, so freshly ingested facts can take time to become retrievable, and per-conversation memory footprint is reported to run far above extraction-based systems
+
+**Choose Cognee if you want:**
+- Knowledge-graph construction from documents and structured data before query time
+- Entity-relationship extraction as the primary product rather than session capture
+- Note: Python-only, and built for document ingestion rather than coding-agent memory
+
---
## Running Your Own Benchmarks
diff --git a/integrations/pi/index.ts b/integrations/pi/index.ts
index e6ad648de..24e71f3c7 100644
--- a/integrations/pi/index.ts
+++ b/integrations/pi/index.ts
@@ -1,4 +1,4 @@
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import path from "node:path";
import crypto from "node:crypto";
@@ -89,6 +89,7 @@ async function callAgentMemory(
method?: "GET" | "POST";
body?: unknown;
baseUrl?: string;
+ timeoutMs?: number;
},
): Promise {
const baseUrl = normalizeBaseUrl(options?.baseUrl || process.env.AGENTMEMORY_URL || DEFAULT_URL);
@@ -105,6 +106,7 @@ async function callAgentMemory(
method,
headers,
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
+ signal: options?.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined,
});
if (!response.ok) return null;
return (await response.json()) as T;
@@ -149,14 +151,49 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
let lastPrompt = "";
let lastHealthOk = false;
+ const toolObserveEnabled = process.env.AGENTMEMORY_TOOL_OBSERVE !== "0";
+
+ // Skips the round-trip when an auto-retry re-submits an identical prompt.
+ const DEDUP_WINDOW_MS = 5 * 60 * 1000;
+ const recentHashes = new Map();
+ function isDuplicate(data: string): boolean {
+ const hash = crypto.createHash("sha256").update(data).digest("hex");
+ const now = Date.now();
+ const prev = recentHashes.get(hash);
+ if (prev !== undefined && now - prev < DEDUP_WINDOW_MS) return true;
+ if (recentHashes.size > 500) {
+ for (const [key, ts] of recentHashes) {
+ if (now - ts >= DEDUP_WINDOW_MS) recentHashes.delete(key);
+ }
+ }
+ recentHashes.set(hash, now);
+ return false;
+ }
+
async function getHealth() {
return await callAgentMemory("health", { method: "GET" });
}
async function refreshStatus(ctx: { ui: { setStatus: (key: string, text: string) => void } }) {
+ // Bind before the await: ctx goes stale if the session is replaced.
+ let setStatus: (key: string, text: string) => void;
+ try {
+ const ui = ctx.ui;
+ setStatus = ui.setStatus.bind(ui);
+ } catch {
+ return;
+ }
const health = await getHealth();
- lastHealthOk = !!health && (health.status === "healthy" || health.health?.status === "healthy");
- ctx.ui.setStatus("agentmemory", lastHealthOk ? "🧠 agentmemory" : "🧠 agentmemory off");
+ lastHealthOk =
+ !!health &&
+ (health.status === "ok" ||
+ health.status === "healthy" ||
+ health.health?.status === "healthy");
+ try {
+ setStatus("agentmemory", lastHealthOk ? "🧠 agentmemory" : "🧠 agentmemory off");
+ } catch {
+ // status is best-effort
+ }
}
pi.registerCommand("agentmemory-status", {
@@ -209,7 +246,7 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
}),
async execute(_toolCallId, params) {
const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
- body: { query: params.query, limit: params.limit ?? 5 },
+ body: { query: params.query, limit: params.limit ?? 5, project: currentProject },
});
const results = result?.results || [];
return {
@@ -234,7 +271,7 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
}),
async execute(_toolCallId, params) {
const result = await callAgentMemory>("remember", {
- body: { content: params.content, type: params.type || "fact" },
+ body: { content: params.content, type: params.type || "fact", project: currentProject },
});
if (!result) {
return {
@@ -255,6 +292,12 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
currentCwd = process.cwd();
currentProject = resolveProjectName(currentCwd);
await refreshStatus(ctx);
+ // After refreshStatus: that is where lastHealthOk is first populated.
+ if (lastHealthOk) {
+ await callAgentMemory("session/start", {
+ body: { sessionId, project: currentProject, cwd: currentCwd },
+ });
+ }
});
pi.on("before_agent_start", async (event, ctx) => {
@@ -263,8 +306,21 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
lastPrompt = event.prompt?.trim() || "";
if (!lastPrompt) return;
+ if (lastHealthOk && !isDuplicate(`prompt_submit:${sessionId}:${lastPrompt}`)) {
+ void callAgentMemory("observe", {
+ body: {
+ hookType: "prompt_submit",
+ sessionId,
+ project: currentProject,
+ cwd: currentCwd,
+ timestamp: new Date().toISOString(),
+ data: { prompt: lastPrompt },
+ },
+ });
+ }
+
const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
- body: { query: lastPrompt, limit: 5 },
+ body: { query: lastPrompt, limit: 5, project: currentProject },
});
const results = result?.results || [];
const recallBlock = results.length
@@ -280,6 +336,39 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
};
});
+ pi.on("tool_result", (event) => {
+ if (!toolObserveEnabled || !lastHealthOk || !sessionId) return;
+ const toolName = event.toolName;
+ if (!toolName) return;
+ let input = "";
+ try {
+ input = typeof event.input === "string" ? event.input : JSON.stringify(event.input ?? {});
+ } catch {
+ // non-serializable
+ }
+ let output = "";
+ try {
+ output = typeof event.content === "string" ? event.content : JSON.stringify(event.content ?? "");
+ } catch {
+ // non-serializable
+ }
+ void callAgentMemory("observe", {
+ body: {
+ hookType: "post_tool_use",
+ sessionId,
+ project: currentProject,
+ cwd: currentCwd,
+ timestamp: new Date().toISOString(),
+ data: {
+ tool_name: toolName,
+ tool_input: input.slice(0, 8000),
+ tool_output: output.slice(0, 8000),
+ ...(event.isError ? { tool_error: true } : {}),
+ },
+ },
+ });
+ });
+
pi.on("agent_end", async (event) => {
if (!lastHealthOk || !lastPrompt) return;
const assistantText = getLastAssistantText(event.messages as unknown[]);
@@ -293,10 +382,22 @@ export default function agentmemoryExtension(pi: ExtensionAPI) {
timestamp: new Date().toISOString(),
data: {
tool_name: "conversation",
- tool_input: lastPrompt.slice(0, 500),
- tool_output: assistantText.slice(0, 4000),
+ tool_input: lastPrompt.slice(0, 8000),
+ tool_output: assistantText.slice(0, 8000),
},
},
});
});
+
+ pi.on("session_shutdown", async (event) => {
+ // /new, /resume, /fork and reloads fire this too; only quit ends the session.
+ if (event.reason !== "quit") return;
+ if (!lastHealthOk || !sessionId) return;
+ // session/end already fans out the summary server-side (#1203).
+ await callAgentMemory("session/end", {
+ body: { sessionId },
+ timeoutMs: 5_000,
+ });
+ void callAgentMemory("consolidate", { body: {} });
+ });
}
diff --git a/integrations/pi/package.json b/integrations/pi/package.json
index eec302de0..fdc37b3b7 100644
--- a/integrations/pi/package.json
+++ b/integrations/pi/package.json
@@ -1,5 +1,32 @@
{
"name": "agentmemory-pi-extension",
+ "version": "0.1.0",
"private": true,
- "type": "module"
+ "description": "agentmemory extension for the pi coding agent: memory recall on agent start, capture on agent end, memory_search / memory_save / memory_health tools, /agentmemory-status command",
+ "type": "module",
+ "license": "Apache-2.0",
+ "keywords": [
+ "pi-package",
+ "agentmemory",
+ "memory"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/rohitg00/agentmemory.git",
+ "directory": "integrations/pi"
+ },
+ "files": [
+ "index.ts",
+ "security.ts",
+ "README.md"
+ ],
+ "pi": {
+ "extensions": [
+ "./index.ts"
+ ]
+ },
+ "peerDependencies": {
+ "@earendil-works/pi-coding-agent": "*",
+ "typebox": "*"
+ }
}
diff --git a/package.json b/package.json
index 79b716c92..ed9b5325d 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
"files": [
"dist/",
"plugin/",
+ "integrations/pi/",
"iii-config.yaml",
"iii-config.docker.yaml",
"docker-compose.yml",
diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts
index 1a1d04268..f54fc6be1 100644
--- a/plugin/opencode/agentmemory-capture.ts
+++ b/plugin/opencode/agentmemory-capture.ts
@@ -52,11 +52,12 @@ async function observe(
hookType: string,
data: Record,
): Promise {
+ const proj = projectFor(sessionId);
await post("/observe", {
hookType,
sessionId,
- project: projectName,
- cwd: projectCwd,
+ project: proj.name,
+ cwd: proj.cwd,
timestamp: new Date().toISOString(),
data,
});
@@ -64,27 +65,45 @@ async function observe(
let activeSessionId: string | null = null;
let pendingConfig: Record | 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;
+// Default scope resolved at plugin init (same resolution order as the hooks'
+// resolveProject: env override, git toplevel basename, cwd basename). In a
+// long-lived OpenCode process serving multiple directories these defaults are
+// only a fallback — attribution is per-session via sessionProjects, resolved
+// from each session's own directory at session.created. Module-level-only
+// state recorded home-directory sessions under whatever repo loaded first.
+let defaultProjectName: string | null = null;
+let defaultProjectCwd: string | null = null;
+const sessionProjects = new Map();
+
+function projectFor(sessionId: string): { name: string | null; cwd: string | null } {
+ const p = sessionProjects.get(sessionId);
+ return p ?? { name: defaultProjectName, cwd: defaultProjectCwd };
+}
+
+const projectNameCache = new Map();
function resolveProjectName(dir: string): string {
const explicit = process.env.AGENTMEMORY_PROJECT_NAME?.trim();
if (explicit) return explicit;
+ const cached = projectNameCache.get(dir);
+ if (cached !== undefined) return cached;
try {
const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: dir,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
}).trim();
- if (top) return basename(top);
+ if (top) {
+ const name = basename(top);
+ projectNameCache.set(dir, name);
+ return name;
+ }
} catch {
// not a git repo, fall through
}
- return basename(dir) || dir;
+ const fallback = basename(dir) || dir;
+ projectNameCache.set(dir, fallback);
+ return fallback;
}
const stashedFiles = new Map>();
const seenSubtaskIds = new Map>();
@@ -119,6 +138,7 @@ function pruneSessionMaps(sid: string): void {
stashedFiles.delete(sid);
seenSubtaskIds.delete(sid);
seenToolCallIds.delete(sid);
+ sessionProjects.delete(sid);
}
function safeSlice(v: unknown, max: number): string {
@@ -194,8 +214,8 @@ function extractErrorMessage(err: unknown): string {
}
export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
- projectCwd = ctx.worktree || ctx.project?.id || process.cwd();
- projectName = resolveProjectName(projectCwd);
+ defaultProjectCwd = ctx.worktree || ctx.project?.id || process.cwd();
+ defaultProjectName = resolveProjectName(defaultProjectCwd);
return {
event: async ({ event }) => {
@@ -215,13 +235,28 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
// and another `session.created` event during the await could
// rebind it, causing context to be cached against the wrong key.
const sessionId = activeSessionId;
+ // Attribute this session to its own directory when the event
+ // carries one; a multi-directory OpenCode process otherwise
+ // records every session under whichever repo loaded the plugin.
+ const sessionDir =
+ typeof info?.directory === "string" && info.directory
+ ? info.directory
+ : defaultProjectCwd;
+ let proj: { name: string | null; cwd: string | null };
+ if (sessionDir) {
+ const entry = { cwd: sessionDir, name: resolveProjectName(sessionDir) };
+ sessionProjects.set(sessionId, entry);
+ proj = entry;
+ } else {
+ proj = projectFor(sessionId);
+ }
const startResult = await postJson("/session/start", {
sessionId,
title: info?.title ?? null,
parentID: info?.parentID ?? null,
version: info?.version ?? null,
- project: projectName,
- cwd: projectCwd,
+ project: proj.name,
+ cwd: proj.cwd,
});
// cache the context returned at session/start so the
// chat.system.transform hook injects it without a second fetch.
@@ -299,10 +334,8 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
post("/crystals/auto", { olderThanDays: 7 }, 30000);
post("/consolidate-pipeline", { tier: "all", force: true }, 30000);
if (sid === activeSessionId) activeSessionId = null;
- stashedFiles.delete(sid);
+ pruneSessionMaps(sid);
startContextCache.delete(sid);
- seenSubtaskIds.delete(sid);
- seenToolCallIds.delete(sid);
contextInjectedSessions.delete(sid);
}
@@ -639,7 +672,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
if (typeof ctx !== "string" || ctx.length === 0) {
const result = await postJson("/context", {
sessionId: sid,
- project: projectName,
+ project: projectFor(sid).name,
});
ctx = (result as any)?.context;
} else {
@@ -677,7 +710,7 @@ export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
const result = await postJson("/context", {
sessionId: sid,
- project: projectName,
+ project: projectFor(sid).name,
});
const ctx = (result as any)?.context;
if (typeof ctx === "string" && ctx.length > 0) {
diff --git a/plugin/scripts/stop.mjs b/plugin/scripts/stop.mjs
index 0b1c43b0b..41fda645d 100755
--- a/plugin/scripts/stop.mjs
+++ b/plugin/scripts/stop.mjs
@@ -24,12 +24,6 @@ async function main() {
if (!data || typeof data !== "object") return;
if (isSdkChildContext(data)) return;
const sessionId = data.session_id || data.sessionId || "unknown";
- fetch(`${REST_URL}/agentmemory/summarize`, {
- method: "POST",
- headers: authHeaders(),
- body: JSON.stringify({ sessionId }),
- signal: AbortSignal.timeout(12e4)
- }).catch(() => {});
fetch(`${REST_URL}/agentmemory/session/end`, {
method: "POST",
headers: authHeaders(),
diff --git a/plugin/skills/agentmemory-agents/REFERENCE.md b/plugin/skills/agentmemory-agents/REFERENCE.md
index 8943cfa4b..a137de961 100644
--- a/plugin/skills/agentmemory-agents/REFERENCE.md
+++ b/plugin/skills/agentmemory-agents/REFERENCE.md
@@ -3,7 +3,7 @@
Generated from `src/cli/connect/index.ts`. Do not edit the block below by hand; run `npm run skills:gen` after adding or removing an adapter.
-`agentmemory connect ` wires the memory server into a host agent. 19 adapters:
+`agentmemory connect ` wires the memory server into a host agent. 20 adapters:
| Agent | Name | Protocol |
| --- | --- | --- |
@@ -16,13 +16,14 @@ Generated from `src/cli/connect/index.ts`. Do not edit the block below by hand;
| GitHub Copilot CLI | `copilot-cli` | Using MCP. Install the plugin too for full hooks/skills coverage. |
| Cursor | `cursor` | Using MCP (the only protocol Cursor speaks). Memory bridge runs at :3111 underneath. |
| Droid (Factory.ai) | `droid` | Using MCP via ~/.factory/mcp.json. The `/mcp` slash command inside droid lists configured servers. Pass --with-hooks to also install the native ~/.factory/hooks.json auto-capture hooks. |
+| DeepSeek Harness | `dsh` | Using MCP via $DSH_HOME/cordis.patch.yml (the home-level patch layer every profile loads). Tools appear as mcp__agentmemory__*. Pass --with-hooks to also wire auto-capture through Harness's Claude Code hook bridge. |
| Gemini CLI | `gemini-cli` | Using MCP (the only protocol Gemini CLI speaks). Memory bridge runs at :3111 underneath. |
| Hermes Agent | `hermes` | Using MCP. Hooks are also available, see https://github.com/rohitg00/agentmemory/tree/main/integrations/hermes. |
| Kiro | `kiro` | Using MCP via ~/.kiro/settings/mcp.json (user-level). Workspace overrides live in .kiro/settings/mcp.json. |
| OpenClaw | `openclaw` | Using MCP. Hooks are also available, see https://github.com/rohitg00/agentmemory/tree/main/integrations/openclaw. |
| OpenCode | `opencode` | Using MCP via ~/.config/opencode/opencode.json (top-level `mcp` key). For full auto-capture, also install the bundled plugin in plugin/opencode/. |
| OpenHuman | `openhuman` | Using native hooks (REST API at :3111). MCP not required. |
-| pi | `pi` | Using native hooks (REST API at :3111). MCP not required. |
+| pi | `pi` | Using native lifecycle hooks against the REST API at :3111 (recall on agent start, capture on agent end, memory tools). MCP not required. |
| Qwen Code | `qwen` | Using MCP via ~/.qwen/settings.json. Qwen Code's hook system can also be wired separately, see docs. |
| Warp | `warp` | Using MCP via ~/.warp/.mcp.json. Skills auto-discover from .claude/skills/ if the Claude Code plugin is also installed. |
| Zed | `zed` | Using MCP via ~/.config/zed/settings.json (key: context_servers). |
diff --git a/plugin/skills/agentmemory-config/REFERENCE.md b/plugin/skills/agentmemory-config/REFERENCE.md
index cbd485d3b..d12aaed73 100644
--- a/plugin/skills/agentmemory-config/REFERENCE.md
+++ b/plugin/skills/agentmemory-config/REFERENCE.md
@@ -3,7 +3,7 @@
Generated by scanning `src/` for `AGENTMEMORY_*` usage. Do not edit the block below by hand; run `npm run skills:gen` after adding or removing a variable. Internal markers ending in two underscores are excluded.
-Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 36 recognized variables:
+Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 37 recognized variables:
- `AGENTMEMORY_AGENT_SCOPE`
- `AGENTMEMORY_ALLOW_AGENT_SDK`
@@ -24,6 +24,7 @@ Configuration is read from the environment and from `~/.agentmemory/.env` (no `e
- `AGENTMEMORY_IMAGE_EMBEDDINGS`
- `AGENTMEMORY_IMAGE_STORE_MAX_BYTES`
- `AGENTMEMORY_INJECT_CONTEXT`
+- `AGENTMEMORY_LLM_NOTHINK`
- `AGENTMEMORY_LLM_TIMEOUT_MS`
- `AGENTMEMORY_MCP_BLOCK`
- `AGENTMEMORY_PROBE_TIMEOUT_MS`
diff --git a/plugin/skills/agentmemory-mcp-tools/REFERENCE.md b/plugin/skills/agentmemory-mcp-tools/REFERENCE.md
index b6b185835..b2a78fe5f 100644
--- a/plugin/skills/agentmemory-mcp-tools/REFERENCE.md
+++ b/plugin/skills/agentmemory-mcp-tools/REFERENCE.md
@@ -40,7 +40,7 @@ agentmemory exposes 54 MCP tools. 8 are in the lean core set (`--tools core` or
| `memory_reflect` | yes | `project`: string, `maxClusters`: number | Traverse the knowledge graph, group related memories by concept clusters, and synthesize higher-order insights via LLM. Returns new and reinforced insights. |
| `memory_relations` | | `memoryId`*: string, `maxHops`: number, `minConfidence`: number | Query the memory relationship graph. |
| `memory_routine_run` | | `routineId`*: string, `project`: string, `initiatedBy`: string | Instantiate a frozen workflow routine, creating actions for each step with proper dependencies. |
-| `memory_save` | yes | `content`*: string, `type`: string, `concepts`: string, `files`: string, `project`: string | Explicitly save an important insight, decision, or pattern to long-term memory. |
+| `memory_save` | yes | `content`*: string, `type`: string, `concepts`: string, `files`: string, `project`: string, `agentId`: string | Explicitly save an important insight, decision, or pattern to long-term memory. |
| `memory_sentinel_create` | | `name`*: string, `type`*: string, `config`: string, `linkedActionIds`: string, `expiresInMs`: number | Create an event-driven sentinel that watches for conditions (webhook, timer, threshold, pattern, approval) and auto-unblocks gated actions when triggered. |
| `memory_sentinel_trigger` | | `sentinelId`*: string, `result`: string | Externally fire a sentinel, providing an optional result payload. Unblocks any gated actions. |
| `memory_sessions` | yes | none | List recent sessions with their status and observation counts. |
diff --git a/src/cli.ts b/src/cli.ts
index 918e011cd..8bbdda932 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -830,6 +830,18 @@ function adoptRunningEngine(): void {
const pids = findEnginePidsByPort(getRestPort());
const enginePid = pids[0];
+ if (enginePid) {
+ // A Docker-forwarded port is held by the VM/proxy process
+ // (com.docker.backend, vpnkit, ...), not the engine. Adopting it
+ // as kind:"native" would make a later `stop` SIGTERM that process.
+ const comm = pidCommand(enginePid);
+ if (isForeignPortHolder(comm)) {
+ vlog(
+ `adoptRunningEngine: refusing to adopt pid ${enginePid} (${comm}) — not the iii engine binary`,
+ );
+ return;
+ }
+ }
if (enginePid && !existingPid) {
writeEnginePidfile(enginePid);
}
@@ -2363,6 +2375,12 @@ async function runDemoBody(base: string) {
sQuery.stop("Search complete");
+ // Only claim the semantic-recall win when the search actually hit.
+ // Without an embedding key this query returns 0 hits, and asserting
+ // success over a visibly failed search reads as a lie.
+ const semanticHits =
+ results.find((r) => r.query === "database performance optimization")
+ ?.hits ?? 0;
const lines = [
`Project: ${demoProject}`,
`Sessions: ${sessions.length} seeded (${totalObs} observations)`,
@@ -2373,8 +2391,16 @@ async function runDemoBody(base: string) {
` ${c.dim("→")} ${c.ok(`${r.hits} hit(s)`)}, top: ${r.topTitle.slice(0, 60)}`,
]),
"",
- c.accent(`Notice: searching "database performance optimization"`),
- c.accent(`found the N+1 query fix — keyword matching can't do that.`),
+ ...(semanticHits > 0
+ ? [
+ c.accent(`Notice: searching "database performance optimization"`),
+ c.accent(`found the N+1 query fix — keyword matching can't do that.`),
+ ]
+ : [
+ c.dim(`Note: "database performance optimization" found nothing —`),
+ c.dim(`semantic recall needs an embedding provider key (e.g.`),
+ c.dim(`OPENAI_API_KEY or GEMINI_API_KEY in ~/.agentmemory/.env).`),
+ ]),
"",
`Viewer: ${c.url(getViewerUrl())}`,
`Clean up with: ${c.dim(`curl -X DELETE "${base}/agentmemory/sessions?project=${demoProject}"`)}`,
@@ -2542,6 +2568,40 @@ async function signalAndWait(
return !pidAlive(pid);
}
+// Shared worker-reap: SIGTERM with a grace window sized for the worker's
+// shutdown flush (index snapshots land via the engine, so the worker must
+// die before the engine does, with time to commit).
+async function stopWorkerPid(pid: number, graceMs: number): Promise {
+ const s = p.spinner();
+ s.start(`Stopping agentmemory worker (pid ${pid})... [flushing state]`);
+ const ok = await signalAndWait(pid, "SIGTERM", graceMs);
+ s.stop(ok ? `Stopped worker pid ${pid}` : `Failed to stop worker pid ${pid}`);
+ return ok;
+}
+
+function pidCommand(pid: number): string {
+ if (IS_WINDOWS) return "";
+ try {
+ return execFileSync("ps", ["-p", String(pid), "-o", "comm="], {
+ encoding: "utf-8",
+ stdio: ["ignore", "pipe", "ignore"],
+ }).trim();
+ } catch {
+ return "";
+ }
+}
+
+// Positive identity beats a denylist: the engine is always the `iii`
+// binary (spawned from PATH or ~/.agentmemory/bin), so anything else
+// holding the port — Docker's proxy, an ssh forward, a stray dev
+// server — must not be adopted or signaled. A denylist of known VM
+// stacks failed open for every name it didn't know.
+function isForeignPortHolder(comm: string): boolean {
+ if (!comm) return false;
+ const base = comm.split("/").pop() || comm;
+ return base !== "iii" && !base.startsWith("iii-");
+}
+
function findEnginePidsByPort(port: number): number[] {
if (IS_WINDOWS) return [];
const lsof = whichBinary("lsof");
@@ -2581,15 +2641,52 @@ async function stopDockerEngine(composeFile: string, port: number): Promise
+ new RegExp(`^\\s+${svc}:`, "m").test(composeText),
+ );
+ if (ownServices.length === 0) {
+ p.log.error(
+ `${composeFile} does not define the agentmemory services (iii-engine/iii-init). Refusing to run an unscoped \`docker compose down\` against it — that would tear down every service in the file.\n\nStop the engine service manually:\n docker compose -f ${composeFile} stop `,
+ );
+ process.exit(1);
+ }
+ const ok = runCommand(
+ dockerBin,
+ ["compose", "-f", composeFile, "rm", "-s", "-f", ...ownServices],
+ {
+ label: `docker compose -f ${composeFile} rm -s -f ${ownServices.join(" ")}`,
+ },
+ );
+ // Clear each piece of state only after its shutdown succeeded, so a
+ // failed stop stays retryable.
+ if (workerStopped) clearWorkerPidfile();
+ if (ok) {
+ clearEnginePidfile();
+ clearEngineState();
+ } else {
p.log.error(
- `docker compose down failed. The engine may still be running on :${port}. Inspect with:\n docker compose -f ${composeFile} ps`,
+ `docker compose rm failed. The engine may still be running on :${port}. Inspect with:\n docker compose -f ${composeFile} ps`,
);
process.exit(1);
}
@@ -2704,14 +2801,19 @@ async function runStop(): Promise {
// persists. Worker SIGTERM grace bumped 3s -> 5s to give a large
// index a real chance to commit before the engine goes away.
for (const pid of workerCandidates) {
- const s = p.spinner();
- s.start(`Stopping agentmemory worker (pid ${pid})... [flushing state]`);
- const ok = await signalAndWait(pid, "SIGTERM", 5000);
- s.stop(ok ? `Stopped worker pid ${pid}` : `Failed to stop worker pid ${pid}`);
- if (!ok) allStopped = false;
+ if (!(await stopWorkerPid(pid, 5000))) allStopped = false;
}
+ const skippedForeign: Array<{ pid: number; comm: string }> = [];
for (const pid of candidates) {
if (workerCandidates.has(pid)) continue;
+ // Last-line guard against a stale/poisoned pidfile or a Docker
+ // port-forward holding :port — signaling com.docker.backend kills
+ // Docker Desktop's whole backend.
+ const comm = pidCommand(pid);
+ if (!force && isForeignPortHolder(comm)) {
+ skippedForeign.push({ pid, comm });
+ continue;
+ }
const s = p.spinner();
s.start(`Stopping iii-engine (pid ${pid})...`);
const ok = await signalAndWait(pid, "SIGTERM", 3000);
@@ -2722,6 +2824,15 @@ async function runStop(): Promise {
clearEnginePidfile();
clearEngineState();
clearWorkerPidfile();
+ if (skippedForeign.length > 0) {
+ const list = skippedForeign
+ .map((sf) => ` pid ${sf.pid} ${sf.comm}`)
+ .join("\n");
+ p.log.error(
+ `Refused to signal process(es) holding :${port} that are not the iii engine:\n${list}\n\nIf the engine runs in Docker, stop it there:\n docker compose ps && docker compose rm -s -f \n\nOr re-run with --force to signal them anyway.`,
+ );
+ process.exit(1);
+ }
if (!allStopped) {
p.log.error("One or more processes survived SIGKILL. Investigate with `ps`.");
process.exit(1);
diff --git a/src/cli/connect/codex.ts b/src/cli/connect/codex.ts
index 3dbc1882f..d0290f850 100644
--- a/src/cli/connect/codex.ts
+++ b/src/cli/connect/codex.ts
@@ -169,8 +169,11 @@ function installCodexHooks(opts: ConnectOptions): ConnectResult {
writeJsonAtomic(CODEX_HOOKS, merged);
logInstalled("Codex hooks (workaround for openai/codex#16430)", CODEX_HOOKS);
+ p.log.warn(
+ "Codex runs only trusted hooks: launch `codex` (the TUI) once and choose \"Trust all and continue\" at the \"Hooks need review\" prompt. `codex exec` never shows the prompt, so hooks stay inert until then.",
+ );
p.log.info(
- "User-scope hooks reference absolute paths under the bundled plugin/ dir. Re-run `agentmemory connect codex --with-hooks` after upgrading agentmemory to refresh them.",
+ "User-scope hooks reference absolute paths under the bundled plugin/ dir. Re-run `agentmemory connect codex --with-hooks` after upgrading agentmemory to refresh them, then re-approve in the TUI.",
);
return {
diff --git a/src/cli/connect/dsh.ts b/src/cli/connect/dsh.ts
new file mode 100644
index 000000000..4f2979fa0
--- /dev/null
+++ b/src/cli/connect/dsh.ts
@@ -0,0 +1,148 @@
+import { existsSync, mkdirSync, readFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, join } from "node:path";
+import * as p from "@clack/prompts";
+import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js";
+import {
+ backupFile,
+ logAlreadyWired,
+ logBackup,
+ logInstalled,
+ readJsonSafe,
+ writeJsonAtomic,
+ writeTextAtomic,
+} from "./util.js";
+import {
+ buildMergedHooks,
+ findPluginRoot,
+ type HookManifest,
+} from "./codex-hooks.js";
+
+// Rows land in the home-level cordis.patch.yml, the patch layer every
+// Harness profile loads; the hooks row reuses the bundled Claude Code
+// hook scripts through Harness's own bridge plugin.
+
+function dshHome(): string {
+ return process.env["DSH_HOME"] || join(homedir(), ".dsh");
+}
+
+// Harness env values are literal strings; no ${VAR:-default} interpolation.
+const MCP_BLOCK = `- insert:
+ - id: agentmemory
+ name: '@deepseek-ai/dsh-mcp-client'
+ config:
+ transport: stdio
+ serverName: agentmemory
+ command: npx
+ args: ['-y', '@agentmemory/mcp']
+ env:
+ AGENTMEMORY_URL: http://localhost:3111
+`;
+
+const MCP_MARKER = "serverName: agentmemory";
+const HOOKS_MARKER = "id: agentmemory-hooks";
+
+function hooksBlock(hooksConfigPath: string): string {
+ return `- insert:
+ - id: agentmemory-hooks
+ name: '@deepseek-ai/dsh-hooks-claude-code'
+ config:
+ configPath: ${JSON.stringify(hooksConfigPath)}
+`;
+}
+
+// Drop the managed top-level block containing `marker`.
+function stripBlock(content: string, marker: string): string {
+ if (!content.includes(marker)) return content;
+ const lines = content.split("\n");
+ const markerIdx = lines.findIndex((l) => l.includes(marker));
+ let start = markerIdx;
+ while (start > 0 && !lines[start].startsWith("- ")) start--;
+ let end = markerIdx + 1;
+ while (end < lines.length && !lines[end].startsWith("- ")) end++;
+ return lines
+ .slice(0, start)
+ .concat(lines.slice(end))
+ .join("\n")
+ .replace(/\n+$/, "\n");
+}
+
+function appendBlock(content: string, block: string): string {
+ const base = content.replace(/\n+$/, "\n");
+ return base.trim() ? `${base}\n${block}` : block;
+}
+
+function installHooksFile(home: string): string {
+ const hooksPath = join(home, "agentmemory.hooks.json");
+ const pluginRoot = findPluginRoot();
+ const existing = readJsonSafe(hooksPath);
+ const merged = buildMergedHooks(existing, pluginRoot, "hooks.codex.json");
+ writeJsonAtomic(hooksPath, merged);
+ return hooksPath;
+}
+
+export const adapter: ConnectAdapter = {
+ name: "dsh",
+ displayName: "DeepSeek Harness",
+ docs: "https://github.com/rohitg00/agentmemory#other-agents",
+ protocolNote:
+ "→ Using MCP via $DSH_HOME/cordis.patch.yml (the home-level patch layer every profile loads). Tools appear as mcp__agentmemory__*. Pass --with-hooks to also wire auto-capture through Harness's Claude Code hook bridge.",
+ category: "native",
+ detect(): boolean {
+ return existsSync(dshHome());
+ },
+ async install(opts: ConnectOptions): Promise {
+ const home = dshHome();
+ const configPath = join(home, "cordis.patch.yml");
+ const existing = existsSync(configPath)
+ ? readFileSync(configPath, "utf-8")
+ : "";
+
+ const wantHooks = opts.withHooks === true;
+ const hasMcp = existing.includes(MCP_MARKER);
+ const hasHooks = existing.includes(HOOKS_MARKER);
+
+ if (hasMcp && (!wantHooks || hasHooks) && !opts.force) {
+ logAlreadyWired(this.displayName, configPath);
+ return { kind: "already-wired", mutatedPath: configPath };
+ }
+
+ if (opts.dryRun) {
+ p.log.info(
+ `[dry-run] Would append the agentmemory mcp-client row${wantHooks ? " and the hooks-claude-code row" : ""} to ${configPath}`,
+ );
+ return { kind: "installed", mutatedPath: configPath };
+ }
+
+ let backupPath: string | undefined;
+ if (existsSync(configPath)) {
+ backupPath = backupFile(configPath, this.name, "yml");
+ logBackup(backupPath);
+ } else {
+ mkdirSync(dirname(configPath), { recursive: true });
+ }
+
+ let next = stripBlock(existing, MCP_MARKER);
+ next = appendBlock(next, MCP_BLOCK);
+
+ if (wantHooks) {
+ const hooksPath = installHooksFile(home);
+ next = stripBlock(next, HOOKS_MARKER);
+ next = appendBlock(next, hooksBlock(hooksPath));
+ p.log.info(`Hook manifest: ${hooksPath}`);
+ }
+
+ writeTextAtomic(configPath, next);
+
+ const written = readFileSync(configPath, "utf-8");
+ if (!written.includes(MCP_MARKER) || (wantHooks && !written.includes(HOOKS_MARKER))) {
+ p.log.error(
+ `Verification failed: ${configPath} did not contain the agentmemory rows after write.`,
+ );
+ return { kind: "skipped", reason: "verification-failed" };
+ }
+
+ logInstalled(this.displayName, configPath);
+ return { kind: "installed", mutatedPath: configPath, backupPath };
+ },
+};
diff --git a/src/cli/connect/index.ts b/src/cli/connect/index.ts
index a0256c7ad..c2c5edecc 100644
--- a/src/cli/connect/index.ts
+++ b/src/cli/connect/index.ts
@@ -12,6 +12,7 @@ import { adapter as codex } from "./codex.js";
import { adapter as continueDev } from "./continue.js";
import { adapter as cursor } from "./cursor.js";
import { adapter as droid } from "./droid.js";
+import { adapter as dsh } from "./dsh.js";
import { adapter as geminiCli } from "./gemini-cli.js";
import { adapter as hermes } from "./hermes.js";
import { adapter as kiro } from "./kiro.js";
@@ -38,6 +39,7 @@ export const ADAPTERS: readonly ConnectAdapter[] = [
continueDev,
zed,
droid,
+ dsh,
opencode,
openclaw,
hermes,
diff --git a/src/cli/connect/pi.ts b/src/cli/connect/pi.ts
index 3056d31d4..63c064911 100644
--- a/src/cli/connect/pi.ts
+++ b/src/cli/connect/pi.ts
@@ -1,12 +1,36 @@
-import { existsSync } from "node:fs";
+import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
-import { join } from "node:path";
+import { dirname, join } from "node:path";
import * as p from "@clack/prompts";
import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js";
+import { findPluginRoot } from "./codex-hooks.js";
+import {
+ backupFile,
+ logAlreadyWired,
+ logBackup,
+ logInstalled,
+ writeTextAtomic,
+} from "./util.js";
+
+// pi auto-discovers ~/.pi/agent/extensions/*/index.ts, so installing is a
+// copy of the bundled extension; no settings.json edit.
const PI_DIR = join(homedir(), ".pi");
const PI_EXT_DIR = join(PI_DIR, "agent", "extensions", "agentmemory");
const DOCS = "https://github.com/rohitg00/agentmemory/tree/main/integrations/pi";
+const EXT_FILES = ["index.ts", "security.ts"] as const;
+
+function findPiSourceDir(): string | null {
+ let packageRoot: string;
+ try {
+ packageRoot = dirname(findPluginRoot());
+ } catch {
+ return null;
+ }
+ const dir = join(packageRoot, "integrations", "pi");
+ const complete = EXT_FILES.every((f) => existsSync(join(dir, f)));
+ return complete ? dir : null;
+}
export const adapter: ConnectAdapter = {
name: "pi",
@@ -14,34 +38,67 @@ export const adapter: ConnectAdapter = {
category: "native",
docs: DOCS,
protocolNote:
- "→ Using native hooks (REST API at :3111). MCP not required.",
+ "→ Using native lifecycle hooks against the REST API at :3111 (recall on agent start, capture on agent end, memory tools). MCP not required.",
detect(): boolean {
return existsSync(PI_DIR);
},
- async install(_opts: ConnectOptions): Promise {
- p.log.warn(
- "pi uses a TypeScript extension file. Automated copy + register isn't implemented yet — manual install required.",
+ async install(opts: ConnectOptions): Promise {
+ const sourceDir = findPiSourceDir();
+ if (!sourceDir) {
+ p.log.error(
+ "Bundled pi extension not found (integrations/pi missing from the install) — reinstall agentmemory.",
+ );
+ return { kind: "skipped", reason: "bundled-extension-missing" };
+ }
+ const sources = EXT_FILES.map((f) => ({
+ name: f,
+ content: readFileSync(join(sourceDir, f), "utf-8"),
+ target: join(PI_EXT_DIR, f),
+ }));
+
+ const upToDate = sources.every(
+ (s) => existsSync(s.target) && readFileSync(s.target, "utf-8") === s.content,
);
- p.note(
- [
- "Run these from the agentmemory repo root:",
- "",
- ` mkdir -p ${PI_EXT_DIR}`,
- ` cp integrations/pi/index.ts ${PI_EXT_DIR}/index.ts`,
- ` cp integrations/pi/security.ts ${PI_EXT_DIR}/security.ts`,
- "",
- "Then add to ~/.pi/agent/settings.json:",
- ' { "extensions": ["~/.pi/agent/extensions/agentmemory"] }',
- "",
- `Full guide: ${DOCS}`,
- ].join("\n"),
- "pi manual install",
+ if (upToDate && !opts.force) {
+ logAlreadyWired(this.displayName, PI_EXT_DIR);
+ return { kind: "already-wired", mutatedPath: PI_EXT_DIR };
+ }
+
+ if (opts.dryRun) {
+ p.log.info(
+ `[dry-run] Would install the pi extension (${EXT_FILES.join(", ")}) into ${PI_EXT_DIR}`,
+ );
+ return { kind: "installed", mutatedPath: PI_EXT_DIR };
+ }
+
+ let backupPath: string | undefined;
+ for (const s of sources) {
+ if (existsSync(s.target) && readFileSync(s.target, "utf-8") !== s.content) {
+ const backup = backupFile(s.target, `${this.name}-${s.name.replace(/\.ts$/, "")}`, "ts");
+ logBackup(backup);
+ backupPath ??= backup;
+ }
+ }
+
+ mkdirSync(PI_EXT_DIR, { recursive: true });
+ for (const s of sources) {
+ writeTextAtomic(s.target, s.content);
+ }
+
+ const verified = sources.every(
+ (s) => existsSync(s.target) && readFileSync(s.target, "utf-8") === s.content,
+ );
+ if (!verified) {
+ p.log.error(`Verification failed: ${PI_EXT_DIR} does not match the bundled extension.`);
+ return { kind: "skipped", reason: "verification-failed" };
+ }
+
+ logInstalled(this.displayName, PI_EXT_DIR);
+ p.log.info(
+ "pi auto-discovers the extension on next launch; a running pi picks it up with /reload. Verify with /agentmemory-status.",
);
- return {
- kind: "stub",
- reason: "ts-extension-copy-not-implemented",
- };
+ return { kind: "installed", mutatedPath: PI_EXT_DIR, backupPath };
},
};
diff --git a/src/cli/connect/types.ts b/src/cli/connect/types.ts
index 7266cf606..eedd76f50 100644
--- a/src/cli/connect/types.ts
+++ b/src/cli/connect/types.ts
@@ -5,8 +5,10 @@ export type ConnectOptions = {
* When true, adapters that ship a native hook config alongside MCP
* additionally write it: Codex (`~/.codex/hooks.json`, workaround for
* openai/codex#16430), Claude Code (`~/.claude/settings.json`, workaround
- * for #508), and Droid (`~/.factory/hooks.json`, its native hooks
- * config). No-op for adapters without a hooks installer.
+ * for #508), Droid (`~/.factory/hooks.json`, its native hooks config),
+ * and DeepSeek Harness (`$DSH_HOME/agentmemory.hooks.json` plus a
+ * hooks-claude-code patch row). No-op for adapters without a hooks
+ * installer.
*/
withHooks?: boolean;
/**
diff --git a/src/cli/connect/util.ts b/src/cli/connect/util.ts
index 580cd4ee7..c47b7f40b 100644
--- a/src/cli/connect/util.ts
+++ b/src/cli/connect/util.ts
@@ -90,9 +90,13 @@ export function readJsonSafe(path: string): T | null {
}
export function writeJsonAtomic(path: string, value: unknown): void {
+ writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+export function writeTextAtomic(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
- writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
+ writeFileSync(tmp, content, "utf-8");
renameSync(tmp, path);
}
diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts
index 6d0493554..926cdbea9 100644
--- a/src/cli/onboarding.ts
+++ b/src/cli/onboarding.ts
@@ -53,7 +53,7 @@ const PROVIDERS: { value: string; label: string; envKey: string | null }[] = [
{ value: "openai", label: "OpenAI — gpt", envKey: "OPENAI_API_KEY" },
{ value: "gemini", label: "Google — gemini", envKey: "GEMINI_API_KEY" },
{ value: "openrouter", label: "OpenRouter — multi-model", envKey: "OPENROUTER_API_KEY" },
- { value: "minimax", label: "MiniMax — minimax-m1", envKey: "MINIMAX_API_KEY" },
+ { value: "minimax", label: "MiniMax — MiniMax-M3", envKey: "MINIMAX_API_KEY" },
{ value: "skip", label: "Skip — BM25-only mode (no LLM key)", envKey: null },
];
diff --git a/src/config.ts b/src/config.ts
index d27c39e4b..426ca20c9 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -90,7 +90,7 @@ function detectProvider(env: Record): ProviderConfig {
if (hasRealValue(env["OPENAI_API_KEY"]) && env["OPENAI_API_KEY_FOR_LLM"] !== "false") {
return {
provider: "openai",
- model: env["OPENAI_MODEL"] || "gpt-4o-mini",
+ model: env["OPENAI_MODEL"] || "gpt-5.6-luna",
maxTokens,
baseURL: env["OPENAI_BASE_URL"],
};
@@ -100,7 +100,7 @@ function detectProvider(env: Record): ProviderConfig {
if (hasRealValue(env["MINIMAX_API_KEY"])) {
return {
provider: "minimax",
- model: env["MINIMAX_MODEL"] || "MiniMax-M2.7",
+ model: env["MINIMAX_MODEL"] || "MiniMax-M3",
maxTokens,
};
}
@@ -108,7 +108,7 @@ function detectProvider(env: Record): ProviderConfig {
if (hasRealValue(env["ANTHROPIC_API_KEY"])) {
return {
provider: "anthropic",
- model: env["ANTHROPIC_MODEL"] || "claude-sonnet-4-20250514",
+ model: env["ANTHROPIC_MODEL"] || "claude-sonnet-5",
maxTokens,
baseURL: env["ANTHROPIC_BASE_URL"],
};
@@ -122,13 +122,12 @@ function detectProvider(env: Record): ProviderConfig {
}
return {
provider: "gemini",
- model: env["GEMINI_MODEL"] || "gemini-2.5-flash",
+ model: env["GEMINI_MODEL"] || "gemini-3.7-flash",
maxTokens,
};
}
if (hasRealValue(env["OPENROUTER_API_KEY"])) {
- const model =
- env["OPENROUTER_MODEL"] || "anthropic/claude-sonnet-4-20250514";
+ const model = env["OPENROUTER_MODEL"] || "anthropic/claude-sonnet-5";
// warn when the configured OpenRouter model is in the
// premium tier and likely to burn money on background compression.
// Captured workload data shows ~$5/35h on claude-sonnet-4 vs
@@ -136,7 +135,7 @@ function detectProvider(env: Record): ProviderConfig {
// Heuristic match avoids hard-coding a pricing table.
if (
!warnPremiumModelShown &&
- /sonnet|opus|gpt-4o(?!.*mini)|gpt-4-turbo/i.test(model) &&
+ /sonnet|opus|gpt-5\.\d+-sol|gpt-4o(?!.*mini)|gpt-4-turbo/i.test(model) &&
env["AGENTMEMORY_SUPPRESS_COST_WARNING"] !== "1" &&
env["AGENTMEMORY_SUPPRESS_COST_WARNING"] !== "true"
) {
@@ -145,7 +144,7 @@ function detectProvider(env: Record): ProviderConfig {
`[agentmemory] OPENROUTER_MODEL=${model} is in the premium tier. ` +
`Background compression on this model can cost $5+/day under active use. ` +
`Cheaper alternatives with comparable quality for memory compression: ` +
- `deepseek/deepseek-v4-pro, deepseek/deepseek-chat, qwen/qwen3-coder. ` +
+ `deepseek/deepseek-v4-flash-0731, deepseek/deepseek-v4-pro, qwen/qwen3-coder. ` +
`See README "Cost-aware model selection" for the full table. ` +
`Set AGENTMEMORY_SUPPRESS_COST_WARNING=1 to silence.\n`,
);
@@ -181,7 +180,7 @@ function detectProvider(env: Record): ProviderConfig {
);
return {
provider: "agent-sdk",
- model: "claude-sonnet-4-20250514",
+ model: "claude-sonnet-5",
maxTokens,
};
}
diff --git a/src/functions/compress-synthetic.ts b/src/functions/compress-synthetic.ts
index 28d17e979..14f757ce1 100644
--- a/src/functions/compress-synthetic.ts
+++ b/src/functions/compress-synthetic.ts
@@ -102,5 +102,6 @@ export function buildSyntheticCompression(
if (raw.modality) result.modality = raw.modality;
if (raw.imageData) result.imageData = raw.imageData;
if (raw.agentId) result.agentId = raw.agentId;
+ if (raw.origin) result.origin = raw.origin;
return result;
}
diff --git a/src/functions/compress.ts b/src/functions/compress.ts
index 0569555e0..c2019e7d6 100644
--- a/src/functions/compress.ts
+++ b/src/functions/compress.ts
@@ -166,6 +166,7 @@ export function registerCompressFunction(
...(imageDescription ? { imageDescription } : {}),
...(data.raw.imageData ? { imageRef: data.raw.imageData } : {}),
...(data.raw.agentId ? { agentId: data.raw.agentId } : {}),
+ ...(data.raw.origin ? { origin: data.raw.origin } : {}),
};
await kv.set(
diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts
index 23854a97f..7bdabf612 100644
--- a/src/functions/export-import.ts
+++ b/src/functions/export-import.ts
@@ -24,6 +24,7 @@ import type {
ExportPagination,
AccessLogExport,
} from "../types.js";
+import { importOrigin } from "../types.js";
import { normalizeAccessLog } from "./access-tracker.js";
import { KV } from "../state/schema.js";
import { checkPayloadFrameSize } from "../state/frame-guard.js";
@@ -31,6 +32,7 @@ import { StateKV } from "../state/kv.js";
import { VERSION } from "../version.js";
import { recordAudit } from "./audit.js";
import { indexRecords } from "./search.js";
+import { resetLessonIndex } from "./lessons.js";
import { logger } from "../logger.js";
// Bounded-concurrency chunk size for the import delete/write loops. A
@@ -365,6 +367,7 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
await kv.list(KV.lessons).catch(() => []),
(l) => kv.delete(KV.lessons, l.id),
);
+ resetLessonIndex();
await runChunked(
await kv.list(KV.insights).catch(() => []),
(i) => kv.delete(KV.insights, i.id),
@@ -428,6 +431,7 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
return;
}
}
+ o.origin = importOrigin(o.origin, o.timestamp);
await kv.set(KV.observations(sessionId), o.id, o);
stats.observations++;
indexObs.push(o);
@@ -448,6 +452,7 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
if (!Array.isArray(memory.sessionIds)) {
memory.sessionIds = [];
}
+ memory.origin = importOrigin(memory.origin, memory.createdAt);
await kv.set(KV.memories, memory.id, memory);
stats.memories++;
indexMems.push(memory);
@@ -607,6 +612,7 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
}
await kv.set(KV.lessons, lesson.id, lesson);
});
+ resetLessonIndex();
}
if (importData.insights) {
await runChunked(importData.insights, async (insight) => {
diff --git a/src/functions/graph.ts b/src/functions/graph.ts
index 86332a303..76340d60f 100644
--- a/src/functions/graph.ts
+++ b/src/functions/graph.ts
@@ -13,6 +13,7 @@ import {
GRAPH_EXTRACTION_SYSTEM,
buildGraphExtractionPrompt,
} from "../prompts/graph-extraction.js";
+import { isGraphExtractionEnabled } from "../config.js";
import { recordAudit } from "./audit.js";
import { logger } from "../logger.js";
@@ -450,6 +451,92 @@ function parseGraphXml(
return { nodes, edges };
}
+const HEURISTIC_EDGE_WEIGHT = 0.4;
+const MAX_HEURISTIC_EDGES_PER_OBS = 12;
+
+export function extractGraphHeuristics(
+ observations: CompressedObservation[],
+): { nodes: GraphNode[]; edges: GraphEdge[] } {
+ const now = new Date().toISOString();
+ const nodes: GraphNode[] = [];
+ const nodeByKey = new Map();
+ const edges: GraphEdge[] = [];
+ const edgeByPair = new Map();
+
+ const nodeFor = (
+ type: GraphNode["type"],
+ name: string,
+ obsId: string,
+ ): GraphNode | null => {
+ const trimmed = name.trim();
+ if (!trimmed) return null;
+ const key = `${type} ${trimmed.toLowerCase()}`;
+ let node = nodeByKey.get(key);
+ if (!node) {
+ node = {
+ id: generateId("gn"),
+ type,
+ name: trimmed,
+ properties: {},
+ sourceObservationIds: [obsId],
+ createdAt: now,
+ };
+ nodeByKey.set(key, node);
+ nodes.push(node);
+ } else if (!node.sourceObservationIds.includes(obsId)) {
+ node.sourceObservationIds.push(obsId);
+ }
+ return node;
+ };
+
+ for (const obs of observations) {
+ let budget = MAX_HEURISTIC_EDGES_PER_OBS;
+ const link = (a: GraphNode | null, b: GraphNode | null): void => {
+ if (!a || !b || a.id === b.id) return;
+ const pair = a.id < b.id ? `${a.id}|${b.id}` : `${b.id}|${a.id}`;
+ const existing = edgeByPair.get(pair);
+ if (existing) {
+ if (!existing.sourceObservationIds.includes(obs.id)) {
+ existing.sourceObservationIds.push(obs.id);
+ }
+ return;
+ }
+ if (budget <= 0) return;
+ budget -= 1;
+ const edge: GraphEdge = {
+ id: generateId("ge"),
+ type: "related_to",
+ sourceNodeId: a.id,
+ targetNodeId: b.id,
+ weight: HEURISTIC_EDGE_WEIGHT,
+ sourceObservationIds: [obs.id],
+ createdAt: now,
+ };
+ edgeByPair.set(pair, edge);
+ edges.push(edge);
+ };
+
+ const fileNodes = (obs.files ?? []).map((f) =>
+ nodeFor("file", f, obs.id),
+ );
+ const conceptNodes = (obs.concepts ?? []).map((c) =>
+ nodeFor("concept", c, obs.id),
+ );
+
+ for (const concept of conceptNodes) {
+ for (const file of fileNodes) link(concept, file);
+ }
+ for (let i = 0; i + 1 < conceptNodes.length; i++) {
+ link(conceptNodes[i], conceptNodes[i + 1]);
+ }
+ for (let i = 0; i + 1 < fileNodes.length; i++) {
+ link(fileNodes[i], fileNodes[i + 1]);
+ }
+ }
+
+ return { nodes, edges };
+}
+
// Shared persistence for a batch of extracted/imported nodes and edges.
// Factored out of mem::graph-extract so structural importers (graphify)
// reuse the exact same name-index upsert, degree bookkeeping, and snapshot
@@ -597,31 +684,60 @@ export function registerGraphFunction(
kv: StateKV,
provider: MemoryProvider,
): void {
- sdk.registerFunction("mem::graph-extract",
+ sdk.registerFunction("mem::graph-extract",
async (data: { observations: CompressedObservation[] }) => {
if (!data.observations || data.observations.length === 0) {
return { success: false, error: "No observations provided" };
}
- const prompt = buildGraphExtractionPrompt(
- data.observations.map((o) => ({
- title: o.title,
- narrative: o.narrative,
- concepts: o.concepts,
- files: o.files,
- type: o.type,
- })),
- );
+ const obsIds = data.observations.map((o) => o.id);
+ let nodes: GraphNode[] = [];
+ let edges: GraphEdge[] = [];
try {
- const response = await provider.compress(
- GRAPH_EXTRACTION_SYSTEM,
- prompt,
+ const heuristic = extractGraphHeuristics(data.observations);
+ nodes = heuristic.nodes;
+ edges = heuristic.edges;
+ } catch (err) {
+ logger.warn("heuristic graph extraction failed", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+
+ const llmEnabled =
+ isGraphExtractionEnabled() && !provider.name.includes("noop");
+ let llmError: string | undefined;
+ if (llmEnabled) {
+ const prompt = buildGraphExtractionPrompt(
+ data.observations.map((o) => ({
+ title: o.title,
+ narrative: o.narrative,
+ concepts: o.concepts,
+ files: o.files,
+ type: o.type,
+ })),
);
+ try {
+ const response = await provider.compress(
+ GRAPH_EXTRACTION_SYSTEM,
+ prompt,
+ );
+ const parsed = parseGraphXml(response, obsIds);
+ nodes = nodes.concat(parsed.nodes);
+ edges = edges.concat(parsed.edges);
+ } catch (err) {
+ llmError = err instanceof Error ? err.message : String(err);
+ logger.error("LLM graph extraction failed", { error: llmError });
+ }
+ }
- const obsIds = data.observations.map((o) => o.id);
- const { nodes, edges } = parseGraphXml(response, obsIds);
+ if (nodes.length === 0 && edges.length === 0) {
+ return llmError
+ ? { success: false, error: llmError }
+ : { success: true, nodesAdded: 0, edgesAdded: 0 };
+ }
+ try {
const { newNodeCount, newEdgeCount } = await persistGraphDelta(
kv,
nodes,
@@ -639,6 +755,7 @@ export function registerGraphFunction(
edges: edges.length,
newNodes: newNodeCount,
newEdges: newEdgeCount,
+ llm: llmEnabled && !llmError,
});
return {
success: true,
diff --git a/src/functions/lessons.ts b/src/functions/lessons.ts
index 0314298ce..d0a4a21ef 100644
--- a/src/functions/lessons.ts
+++ b/src/functions/lessons.ts
@@ -2,8 +2,56 @@ import type { ISdk } from "iii-sdk";
import type { StateKV } from "../state/kv.js";
import { KV, fingerprintId } from "../state/schema.js";
import type { Lesson } from "../types.js";
+import { SearchIndex } from "../state/search-index.js";
+import { lessonToObservation } from "../state/memory-utils.js";
import { recordAudit } from "./audit.js";
+// Dedicated BM25 index for lessons, with the full records cached
+// alongside it. Recall previously listed every lesson from KV and
+// substring-matched per query — O(corpus) per call with no term
+// weighting. Index and record cache are built lazily from one KV list
+// (the same cost a single recall used to pay) and kept current
+// incrementally on save/delete/decay. Confidence x recency reranking
+// stays exactly as before — the index only replaces the relevance term,
+// and the record cache keeps recall at zero KV round-trips.
+let lessonIndex: SearchIndex | null = null;
+const lessonRecords = new Map();
+let lessonIndexBuild: Promise | null = null;
+let lessonIndexGeneration = 0;
+
+export function resetLessonIndex(): void {
+ lessonIndexGeneration++;
+ lessonIndex = null;
+ lessonRecords.clear();
+}
+
+function noteLessonMutation(): void {
+ if (!lessonIndex && lessonIndexBuild) resetLessonIndex();
+}
+
+async function ensureLessonIndex(kv: StateKV): Promise {
+ if (lessonIndex) return lessonIndex;
+ if (!lessonIndexBuild) {
+ const generation = lessonIndexGeneration;
+ lessonIndexBuild = (async () => {
+ const idx = new SearchIndex();
+ const all = await kv.list(KV.lessons);
+ if (generation !== lessonIndexGeneration) return;
+ for (const l of all) {
+ if (!l.deleted) {
+ idx.add(lessonToObservation(l));
+ lessonRecords.set(l.id, l);
+ }
+ }
+ lessonIndex = idx;
+ })().finally(() => {
+ lessonIndexBuild = null;
+ });
+ }
+ await lessonIndexBuild;
+ return lessonIndex ?? ensureLessonIndex(kv);
+}
+
function reinforceLesson(lesson: Lesson): void {
const now = new Date().toISOString();
lesson.reinforcements++;
@@ -35,10 +83,18 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
if (existing && !existing.deleted) {
reinforceLesson(existing);
+ let indexedTextChanged = false;
if (data.context && !existing.context) {
existing.context = data.context;
+ indexedTextChanged = true;
}
await kv.set(KV.lessons, existing.id, existing);
+ lessonRecords.set(existing.id, existing);
+ if (indexedTextChanged && lessonIndex) {
+ lessonIndex.remove(existing.id);
+ lessonIndex.add(lessonToObservation(existing));
+ }
+ noteLessonMutation();
try {
await recordAudit(kv, "lesson_strengthen", "mem::lesson-save", [
@@ -77,6 +133,9 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
};
await kv.set(KV.lessons, lesson.id, lesson);
+ lessonRecords.set(lesson.id, lesson);
+ if (lessonIndex) lessonIndex.add(lessonToObservation(lesson));
+ noteLessonMutation();
try {
await recordAudit(kv, "lesson_save", "mem::lesson-save", [lesson.id]);
@@ -97,41 +156,38 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
return { success: false, error: "query is required" };
}
- const query = data.query.toLowerCase();
const minConfidence = data.minConfidence ?? 0.1;
const limit = data.limit ?? 10;
- let lessons = await kv.list(KV.lessons);
-
- lessons = lessons.filter(
- (l) => !l.deleted && l.confidence >= minConfidence,
- );
-
- if (data.project) {
- lessons = lessons.filter((l) => l.project === data.project);
+ const idx = await ensureLessonIndex(kv);
+ const filtering = !!data.project || minConfidence > 0.1;
+ const fetchLimit = filtering
+ ? Math.max(limit * 10, 100)
+ : Math.max(limit * 5, 50);
+ const hits = idx.search(data.query, fetchLimit);
+ const maxHit = hits.length > 0 ? hits[0].score : 0;
+
+ const scored: Array<{ lesson: Lesson; score: number }> = [];
+ for (let i = 0; i < hits.length; i++) {
+ const l = lessonRecords.get(hits[i].obsId);
+ if (!l || l.deleted || l.confidence < minConfidence) continue;
+ if (data.project && l.project !== data.project) continue;
+
+ const relevance = maxHit > 0 ? hits[i].score / maxHit : 0;
+ const daysSinceReinforced = l.lastReinforcedAt
+ ? (Date.now() - new Date(l.lastReinforcedAt).getTime()) /
+ (1000 * 60 * 60 * 24)
+ : (Date.now() - new Date(l.createdAt).getTime()) /
+ (1000 * 60 * 60 * 24);
+ const recencyBoost = 1 / (1 + daysSinceReinforced * 0.01);
+ scored.push({ lesson: l, score: l.confidence * relevance * recencyBoost });
}
- const scored = lessons
- .map((l) => {
- const text = `${l.content} ${l.context} ${l.tags.join(" ")}`.toLowerCase();
- const terms = query.split(/\s+/).filter((t) => t.length > 1);
- const matchCount = terms.filter((t) => text.includes(t)).length;
- if (matchCount === 0) return null;
-
- const relevance = matchCount / terms.length;
- const daysSinceReinforced = l.lastReinforcedAt
- ? (Date.now() - new Date(l.lastReinforcedAt).getTime()) /
- (1000 * 60 * 60 * 24)
- : (Date.now() - new Date(l.createdAt).getTime()) /
- (1000 * 60 * 60 * 24);
- const recencyBoost = 1 / (1 + daysSinceReinforced * 0.01);
- const score = l.confidence * relevance * recencyBoost;
-
- return { lesson: l, score };
- })
- .filter(Boolean) as Array<{ lesson: Lesson; score: number }>;
-
- scored.sort((a, b) => b.score - a.score);
+ scored.sort(
+ (a, b) =>
+ b.score - a.score ||
+ (a.lesson.id < b.lesson.id ? -1 : a.lesson.id > b.lesson.id ? 1 : 0),
+ );
try {
await recordAudit(kv, "lesson_recall", "mem::lesson-recall", [], {
@@ -192,6 +248,8 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
reinforceLesson(lesson);
await kv.set(KV.lessons, lesson.id, lesson);
+ lessonRecords.set(lesson.id, lesson);
+ noteLessonMutation();
try {
await recordAudit(kv, "lesson_strengthen", "mem::lesson-strengthen", [
@@ -218,6 +276,9 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
lesson.updatedAt = new Date().toISOString();
await kv.set(KV.lessons, lesson.id, lesson);
+ lessonRecords.delete(lesson.id);
+ if (lessonIndex) lessonIndex.remove(lesson.id);
+ noteLessonMutation();
try {
await recordAudit(kv, "lesson_delete", "mem::lesson-delete", [
@@ -285,6 +346,15 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
}
await Promise.all(dirty.map((l) => kv.set(KV.lessons, l.id, l)));
+ for (const l of dirty) {
+ if (l.deleted) {
+ lessonRecords.delete(l.id);
+ if (lessonIndex) lessonIndex.remove(l.id);
+ } else {
+ lessonRecords.set(l.id, l);
+ }
+ }
+ if (dirty.length > 0) noteLessonMutation();
await Promise.all(
auditEvents.map((event) =>
recordAudit(kv, "lesson_strengthen", "mem::lesson-decay-sweep", [event.id], {
diff --git a/src/functions/observe.ts b/src/functions/observe.ts
index 8ad4ba0ff..c1c9f499b 100644
--- a/src/functions/observe.ts
+++ b/src/functions/observe.ts
@@ -1,5 +1,7 @@
import { TriggerAction, type ISdk } from "iii-sdk";
-import type { RawObservation, HookPayload } from "../types.js";
+import type { RawObservation, HookPayload, Origin } from "../types.js";
+
+const TOOL_HOOKS = new Set(["pre_tool_use", "post_tool_use", "post_tool_failure"]);
import { KV, STREAM, generateId } from "../state/schema.js";
import { StateKV } from "../state/kv.js";
import { stripPrivateData } from "./privacy.js";
@@ -63,15 +65,24 @@ export function registerObserveFunction(
let dedupHash: string | undefined;
if (dedupMap) {
- const d =
- typeof payload.data === "object" && payload.data !== null
- ? (payload.data as Record)
- : {};
+ const dataIsObject =
+ typeof payload.data === "object" && payload.data !== null;
+ const d = dataIsObject
+ ? (payload.data as Record)
+ : {};
const toolName = (d["tool_name"] as string) || payload.hookType;
+ // Hash the full payload when tool_input is absent so distinct
+ // events never collapse onto one key.
+ const dedupInput =
+ d["tool_input"] !== undefined
+ ? d["tool_input"]
+ : dataIsObject
+ ? d
+ : payload.data;
dedupHash = dedupMap.computeHash(
payload.sessionId,
toolName,
- d["tool_input"],
+ dedupInput,
);
if (dedupMap.isDuplicate(dedupHash)) {
return { deduplicated: true, sessionId: payload.sessionId };
@@ -87,12 +98,19 @@ export function registerObserveFunction(
sanitizedRaw = stripPrivateData(String(payload.data));
}
+ let originChannel: Origin["channel"] = "agent";
+ if (payload.hookType === "prompt_submit") originChannel = "user";
+ else if (TOOL_HOOKS.has(payload.hookType)) originChannel = "tool";
const raw: RawObservation = {
id: obsId,
sessionId: payload.sessionId,
timestamp: payload.timestamp,
hookType: payload.hookType,
raw: sanitizedRaw,
+ origin: {
+ channel: originChannel,
+ capturedAt: payload.timestamp,
+ },
};
let extractedImage: string | undefined;
@@ -106,6 +124,7 @@ export function registerObserveFunction(
raw.toolName = d["tool_name"] as string | undefined;
raw.toolInput = d["tool_input"];
raw.toolOutput = d["tool_output"] || d["error"];
+ if (raw.origin && raw.toolName) raw.origin.detail = raw.toolName;
}
if (payload.hookType === "prompt_submit") {
raw.userPrompt = d["prompt"] as string | undefined;
diff --git a/src/functions/remember.ts b/src/functions/remember.ts
index 759fddb5f..942226945 100644
--- a/src/functions/remember.ts
+++ b/src/functions/remember.ts
@@ -6,7 +6,7 @@ import { withKeyedLock } from "../state/keyed-mutex.js";
import { memoryToObservation } from "../state/memory-utils.js";
import { deleteAccessLog } from "./access-tracker.js";
import { recordAudit } from "./audit.js";
-import { getSearchIndex, vectorIndexAddGuarded, vectorIndexRemove, flushIndexSave } from "./search.js";
+import { getSearchIndex, isMemoryIndexReady, vectorIndexAddGuarded, vectorIndexRemove, flushIndexSave } from "./search.js";
import { getAgentId } from "../config.js";
import { logger } from "../logger.js";
@@ -69,12 +69,51 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void {
: undefined;
return withKeyedLock("mem:remember", async () => {
- const existingMemories = await kv.list(KV.memories);
+ // Candidate generation: query the BM25 index with the new content
+ // and Jaccard-compare only the top hits, instead of walking the
+ // full memory corpus on every save. The index receives every
+ // memory at save time and is rebuilt at boot, so it covers the
+ // corpus whenever it is non-empty; a cold, never-queried index
+ // falls back to the full scan so supersession never silently
+ // stops working.
+ const idx = getSearchIndex();
+ let candidateMemories: Memory[];
+ try {
+ if (isMemoryIndexReady() && idx.size > 0) {
+ // 50 hits, not 20: the shared index also holds observations,
+ // which occupy slots but never resolve to memories below. A
+ // >0.7-Jaccard duplicate shares most tokens with the query so
+ // it ranks near the top regardless. Only mem_-prefixed ids can
+ // resolve in KV.memories, so skip the guaranteed-miss lookups.
+ const hits = idx
+ .search(data.content, 50)
+ .filter((h) => h.obsId.startsWith("mem_"));
+ const loaded = await Promise.all(
+ hits.map((h) =>
+ kv.get(KV.memories, h.obsId).catch(() => null),
+ ),
+ );
+ candidateMemories = loaded.filter((m): m is Memory => m !== null);
+ } else {
+ candidateMemories = await kv.list(KV.memories);
+ }
+ } catch (err) {
+ // Candidate generation is an optimization; a failure here must
+ // never block the save itself.
+ logger.warn("supersession candidate lookup failed, using full scan", {
+ error: err instanceof Error ? err.message : JSON.stringify(err),
+ });
+ candidateMemories = await kv.list(KV.memories);
+ }
let supersededId: string | undefined;
let supersededVersion = 1;
let supersededMemory: Memory | undefined;
+ // Track the closest sub-threshold match: not similar enough to
+ // supersede, but similar enough that the caller may want to
+ // consolidate. Reported back as a hint; never acted on here.
+ let nearMatch: { id: string; title: string; similarity: number } | undefined;
const lowerContent = data.content.toLowerCase();
- for (const existing of existingMemories) {
+ for (const existing of candidateMemories) {
if (existing.isLatest === false) continue;
// Never supersede a memory that belongs to a different project.
// Both sides must have an explicit project for the guard to engage;
@@ -93,6 +132,12 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void {
supersededMemory = existing;
break;
}
+ if (
+ similarity > 0.4 &&
+ (!nearMatch || similarity > nearMatch.similarity)
+ ) {
+ nearMatch = { id: existing.id, title: existing.title, similarity };
+ }
}
// stamp the agent role on the memory so future recall can
@@ -122,6 +167,7 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void {
(id): id is string => typeof id === "string" && id.length > 0,
),
isLatest: true,
+ origin: { channel: "agent", capturedAt: now },
...(callAgentId ? { agentId: callAgentId } : {}),
...(project !== undefined && { project }),
};
@@ -133,6 +179,14 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void {
if (supersededMemory) {
supersededMemory.isLatest = false;
await kv.set(KV.memories, supersededMemory.id, supersededMemory);
+ // The superseded version stays in KV (the viewer's version
+ // chain reads it there) but leaves both search indexes:
+ // recall returning an outdated fact as if current is worse
+ // than returning nothing.
+ try {
+ getSearchIndex().remove(supersededMemory.id);
+ } catch {}
+ vectorIndexRemove(supersededMemory.id);
}
await kv.set(KV.memories, memory.id, memory);
@@ -171,7 +225,20 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void {
type: memory.type,
project: memory.project,
});
- return { success: true, memory };
+ // similarTo is advisory only: a close-but-not-superseding match
+ // the caller may want to consolidate via memory_update/forget.
+ return {
+ success: true,
+ memory,
+ ...(nearMatch && !supersededId
+ ? {
+ similarTo: {
+ ...nearMatch,
+ similarity: Math.round(nearMatch.similarity * 100) / 100,
+ },
+ }
+ : {}),
+ };
});
},
);
diff --git a/src/functions/replay.ts b/src/functions/replay.ts
index e91850503..246a6d61b 100644
--- a/src/functions/replay.ts
+++ b/src/functions/replay.ts
@@ -9,9 +9,11 @@ import type {
RawObservation,
Session,
} from "../types.js";
+import { importOrigin } from "../types.js";
import type { StateKV } from "../state/kv.js";
import { KV, generateId, fingerprintId } from "../state/schema.js";
import { parseJsonlText } from "../replay/jsonl-parser.js";
+import { resetLessonIndex } from "./lessons.js";
import { projectTimeline, type Timeline } from "../replay/timeline.js";
import { safeAudit } from "./audit.js";
import { buildSyntheticCompression } from "./compress-synthetic.js";
@@ -157,6 +159,7 @@ async function deriveCrystalAndLessons(
lessonIds.push(lessonId);
} catch {}
}
+ if (lessonIds.length > 0) resetLessonIndex();
// Content-addressed on sessionId so re-importing the same session
// upserts the crystal in place instead of creating a new one.
@@ -436,6 +439,11 @@ export function registerReplayFunctions(sdk: ISdk, kv: StateKV): void {
await Promise.all(
parsed.observations.map(async (obs) => {
const synthetic = buildSyntheticCompression(obs);
+ synthetic.origin = importOrigin(
+ synthetic.origin,
+ synthetic.timestamp,
+ "jsonl",
+ );
compressed.push(synthetic);
await kv.set(KV.observations(parsed.sessionId), obs.id, synthetic);
}),
diff --git a/src/functions/search.ts b/src/functions/search.ts
index 9bcda6ae0..950828953 100644
--- a/src/functions/search.ts
+++ b/src/functions/search.ts
@@ -14,6 +14,22 @@ let index: SearchIndex | null = null
let vectorIndex: VectorIndex | null = null
let currentEmbeddingProvider: EmbeddingProvider | null = null
+// Hybrid ranking hook for mem::search. Wired by index.ts once the
+// hybrid searcher exists (it is constructed after this module's
+// registration runs). When set and the vector index has entries,
+// mem::search ranks candidates through the full BM25+vector+graph
+// fusion instead of BM25 alone — previously only mem::smart-search got
+// hybrid ranking while the primary recall surface stayed keyword-only.
+type HybridRanker = (
+ query: string,
+ limit: number,
+) => Promise>
+let hybridRanker: HybridRanker | null = null
+
+export function setHybridRanker(fn: HybridRanker | null): void {
+ hybridRanker = fn
+}
+
// Dedupes the lazy cold-start rebuild kicked off from the mem::search
// request path. A full rebuildIndex walks every observation across every
// session, so N concurrent queries against an empty index would each
@@ -23,6 +39,11 @@ let currentEmbeddingProvider: EmbeddingProvider | null = null
// duplicates. The boot-time rebuild in index.ts is unaffected.
let rebuildPromise: Promise | null = null
+let memoryIndexReady = false
+export function isMemoryIndexReady(): boolean {
+ return memoryIndexReady
+}
+
export function getSearchIndex(): SearchIndex {
if (!index) index = new SearchIndex()
return index
@@ -290,6 +311,7 @@ export async function indexRecords(
export async function rebuildIndex(kv: StateKV): Promise {
const idx = getSearchIndex()
idx.clear()
+ memoryIndexReady = false
// BM25 clear above wipes stale doc entries; the vector index has the
// symmetric concern — memories/observations deleted between runs
@@ -302,8 +324,10 @@ export async function rebuildIndex(kv: StateKV): Promise {
// entries vanish from BM25 on every restart even after the live-write
// fix in remember.ts.
let memories: Memory[] = []
+ let memoriesLoaded = false
try {
memories = await kv.list(KV.memories)
+ memoriesLoaded = true
} catch (err) {
logger.warn('rebuildIndex: failed to load memories', {
error: err instanceof Error ? err.message : String(err),
@@ -337,6 +361,7 @@ export async function rebuildIndex(kv: StateKV): Promise {
}
indexed += await indexRecords([], memories)
+ if (memoriesLoaded) memoryIndexReady = true
return indexed
}
@@ -448,7 +473,33 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
// rank lower than cross-agent ones in the hybrid score.
const filtering = !!(projectFilter || cwdFilter || filterAgentId)
const fetchLimit = filtering ? Math.max(effectiveLimit * 10, 100) : effectiveLimit
- const results = idx.search(query, fetchLimit)
+ // Hybrid results carry the observation the ranker already loaded,
+ // so the load pass below doesn't refetch every record it just
+ // enriched.
+ let results: Array<{
+ obsId: string
+ sessionId: string
+ score: number
+ observation?: CompressedObservation
+ }>
+ if (hybridRanker && vectorIndex && vectorIndex.size > 0) {
+ try {
+ const hybrid = await hybridRanker(query, fetchLimit)
+ results = hybrid.map((r) => ({
+ obsId: r.observation.id,
+ sessionId: r.sessionId,
+ score: r.combinedScore,
+ observation: r.observation,
+ }))
+ } catch (err) {
+ logger.warn("hybrid ranking failed, falling back to keyword search", {
+ error: err instanceof Error ? err.message : String(err),
+ })
+ results = idx.search(query, fetchLimit)
+ }
+ } else {
+ results = idx.search(query, fetchLimit)
+ }
// Resolve session -> project/cwd once per sessionId we touch.
const sessionCache = new Map()
@@ -522,6 +573,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
// sessionId, so the observation key never exists (#265).
const obsResults = await Promise.all(
candidates.map(async (r) => {
+ if (r.observation) return r.observation
const obs = await kv
.get(KV.observations(r.sessionId), r.obsId)
.catch(() => null)
diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts
index 5eacd12e1..4f666781a 100644
--- a/src/hooks/stop.ts
+++ b/src/hooks/stop.ts
@@ -40,13 +40,7 @@ async function main() {
const sessionId = ((data.session_id || data.sessionId) as string) || "unknown";
- fetch(`${REST_URL}/agentmemory/summarize`, {
- method: "POST",
- headers: authHeaders(),
- body: JSON.stringify({ sessionId }),
- signal: AbortSignal.timeout(120000),
- }).catch(() => {});
-
+ // session/end already fans out the summary server-side (#1203).
fetch(`${REST_URL}/agentmemory/session/end`, {
method: "POST",
headers: authHeaders(),
diff --git a/src/index.ts b/src/index.ts
index 198a6dc3d..26e5a6343 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -39,6 +39,7 @@ import {
setVectorIndex,
setEmbeddingProvider,
setIndexPersistence,
+ setHybridRanker,
} from "./functions/search.js";
import { registerContextFunction } from "./functions/context.js";
import { registerSummarizeFunction } from "./functions/summarize.js";
@@ -272,11 +273,11 @@ async function main() {
);
}
- if (isGraphExtractionEnabled()) {
- registerGraphFunction(sdk, kv, provider);
- registerGraphImportFunction(sdk, kv);
- bootLog(`Knowledge graph: extraction enabled`);
- }
+ registerGraphFunction(sdk, kv, provider);
+ registerGraphImportFunction(sdk, kv);
+ bootLog(
+ `Knowledge graph: structural extraction on (LLM relations ${isGraphExtractionEnabled() ? "enabled" : "off"})`,
+ );
registerConsolidationPipelineFunction(sdk, kv, provider);
bootLog(`Consolidation pipeline: registered (CONSOLIDATION_ENABLED=${isConsolidationEnabled() ? "true" : "false"})`);
@@ -386,9 +387,10 @@ async function main() {
graphWeight,
);
- registerSmartSearchFunction(sdk, kv, (query, limit) =>
- hybridSearch.search(query, limit),
- );
+ const hybridRanker = (query: string, limit: number) =>
+ hybridSearch.search(query, limit);
+ registerSmartSearchFunction(sdk, kv, hybridRanker);
+ setHybridRanker(hybridRanker);
registerRecentSearchesSweepFunction(sdk, kv);
registerApiTriggers(sdk, kv, secret, metricsStore, provider);
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index 13240003b..ef26427aa 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -186,6 +186,10 @@ export function registerMcpEndpoints(
typeof args.project === "string" && args.project.trim().length > 0
? args.project.trim()
: undefined;
+ const saveAgentId =
+ typeof args.agentId === "string" && args.agentId.trim().length > 0
+ ? (args.agentId as string).trim()
+ : undefined;
const result = await sdk.trigger({ function_id: "mem::remember", payload: {
content: args.content,
@@ -193,6 +197,7 @@ export function registerMcpEndpoints(
concepts,
files,
...(project !== undefined && { project }),
+ ...(saveAgentId !== undefined && { agentId: saveAgentId }),
} });
return {
status_code: 200,
diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts
index 1ace150b1..4a8967246 100644
--- a/src/mcp/standalone.ts
+++ b/src/mcp/standalone.ts
@@ -99,6 +99,8 @@ interface Validated {
type?: string;
concepts?: string[];
files?: string[];
+ project?: string;
+ agentId?: string;
query?: string;
limit?: number;
format?: string;
@@ -122,6 +124,15 @@ function validate(toolName: string, args: Record): Validated {
v.type = (args["type"] as string) || "fact";
v.concepts = normalizeList(args["concepts"]);
v.files = normalizeList(args["files"]);
+ // The tool schema exposes project (and now agentId); dropping them
+ // here silently broke project/agent scoping through the stdio
+ // package specifically.
+ if (typeof args["project"] === "string" && args["project"].trim()) {
+ v.project = args["project"].trim();
+ }
+ if (typeof args["agentId"] === "string" && args["agentId"].trim()) {
+ v.agentId = args["agentId"].trim();
+ }
return v;
}
case "memory_recall":
@@ -180,6 +191,8 @@ async function handleProxy(
type: v.type,
concepts: v.concepts,
files: v.files,
+ ...(v.project !== undefined && { project: v.project }),
+ ...(v.agentId !== undefined && { agentId: v.agentId }),
}),
});
return textResponse(result);
diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts
index 464cb3b0c..1225b4ce7 100644
--- a/src/mcp/tools-registry.ts
+++ b/src/mcp/tools-registry.ts
@@ -83,6 +83,12 @@ export const CORE_TOOLS: McpToolDef[] = [
"started. Do not use filesystem paths or ad-hoc display names — those " +
"change across machines and will silently break project scoping.",
},
+ agentId: {
+ type: "string",
+ description:
+ "Agent identity to scope this memory to. When set, agent-scoped recall " +
+ "and search only surface it for the same agentId. Omit for shared memory.",
+ },
},
required: ["content"],
},
diff --git a/src/prompts/graph-extraction.ts b/src/prompts/graph-extraction.ts
index 4f1049c1a..cb6d47ad8 100644
--- a/src/prompts/graph-extraction.ts
+++ b/src/prompts/graph-extraction.ts
@@ -31,5 +31,9 @@ export function buildGraphExtractionPrompt(
`[${i + 1}] Type: ${o.type}\nTitle: ${o.title}\nNarrative: ${o.narrative}\nConcepts: ${(o.concepts ?? []).join(", ")}\nFiles: ${(o.files ?? []).join(", ")}`,
)
.join("\n\n");
- return `Extract entities and relationships from these observations:\n\n${items}`;
+ // Some local models default to a hidden reasoning pass that consumes
+ // most of the token budget before any output. The suffix is their
+ // documented soft switch to skip it; other models ignore the token.
+ const noThink = process.env.AGENTMEMORY_LLM_NOTHINK === "1" ? "\n/no_think" : "";
+ return `Extract entities and relationships from these observations:\n\n${items}${noThink}`;
}
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 0ec3feba0..0ecef1496 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -35,19 +35,17 @@ function requireEnvVar(key: string): string {
function defaultModelFor(providerType: ProviderConfig["provider"]): string {
switch (providerType) {
case "openai":
- return getEnvVar("OPENAI_MODEL") || "gpt-4o-mini";
+ return getEnvVar("OPENAI_MODEL") || "gpt-5.6-luna";
case "anthropic":
- return getEnvVar("ANTHROPIC_MODEL") || "claude-sonnet-4-20250514";
+ return getEnvVar("ANTHROPIC_MODEL") || "claude-sonnet-5";
case "gemini":
- return getEnvVar("GEMINI_MODEL") || "gemini-2.5-flash";
+ return getEnvVar("GEMINI_MODEL") || "gemini-3.7-flash";
case "openrouter":
- return (
- getEnvVar("OPENROUTER_MODEL") || "anthropic/claude-sonnet-4-20250514"
- );
+ return getEnvVar("OPENROUTER_MODEL") || "anthropic/claude-sonnet-5";
case "minimax":
- return getEnvVar("MINIMAX_MODEL") || "MiniMax-M2.7";
+ return getEnvVar("MINIMAX_MODEL") || "MiniMax-M3";
case "agent-sdk":
- return "claude-sonnet-4-20250514";
+ return "claude-sonnet-5";
case "noop":
default:
return "noop";
diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts
index 72fc9ec90..77c0dcd27 100644
--- a/src/providers/minimax.ts
+++ b/src/providers/minimax.ts
@@ -10,8 +10,8 @@ import { fetchWithTimeout } from './_fetch.js'
*
* Required env vars (loaded from ~/.agentmemory/.env or process.env):
* MINIMAX_API_KEY — your MiniMax API key
- * MINIMAX_MODEL — model name (default: MiniMax-M2.7)
- * MAX_TOKENS — max output tokens (default: 800; MiniMax-M2.7 needs ≤800)
+ * MINIMAX_MODEL — model name (default: MiniMax-M3)
+ * MAX_TOKENS — max output tokens (default: 4096)
*
* Optional:
* MINIMAX_BASE_URL — base URL without path (default: https://api.minimax.io/anthropic)
diff --git a/src/providers/openai.ts b/src/providers/openai.ts
index 438b2f4e7..31ee158eb 100644
--- a/src/providers/openai.ts
+++ b/src/providers/openai.ts
@@ -9,7 +9,7 @@ import {
normalizeBaseUrl,
} from "./_openai-shared.js";
-const DEFAULT_MODEL = "gpt-4o-mini";
+const DEFAULT_MODEL = "gpt-5.6-luna";
const DEFAULT_TIMEOUT_MS = 60_000;
/**
@@ -29,7 +29,7 @@ const DEFAULT_TIMEOUT_MS = 60_000;
* Optional:
* OPENAI_BASE_URL — base URL without path (default: https://api.openai.com).
* Azure: https://.openai.azure.com/openai/deployments/
- * OPENAI_MODEL — model name (default: gpt-4o-mini)
+ * OPENAI_MODEL — model name (default: gpt-5.6-luna)
* OPENAI_API_VERSION — Azure api-version query param (default: 2024-08-01-preview)
* OPENAI_TIMEOUT_MS — outbound fetch timeout in ms (OpenAI-scoped alias,
* takes precedence over AGENTMEMORY_LLM_TIMEOUT_MS
diff --git a/src/state/hybrid-search.ts b/src/state/hybrid-search.ts
index d234a3efc..dc762a6e0 100644
--- a/src/state/hybrid-search.ts
+++ b/src/state/hybrid-search.ts
@@ -70,7 +70,11 @@ export class HybridSearch {
}
return Array.from(merged.values())
- .sort((a, b) => b.combinedScore - a.combinedScore)
+ .sort(
+ (a, b) =>
+ b.combinedScore - a.combinedScore ||
+ (a.obsId < b.obsId ? -1 : a.obsId > b.obsId ? 1 : 0),
+ )
.slice(0, limit);
}
@@ -191,35 +195,50 @@ export class HybridSearch {
}
});
- const hasVector = vectorResults.length > 0;
- const hasGraph = graphResults.length > 0;
-
- let effectiveBm25W = this.bm25Weight;
- let effectiveVectorW = hasVector ? this.vectorWeight : 0;
- let effectiveGraphW = hasGraph ? this.graphWeight : 0;
-
- const totalW = effectiveBm25W + effectiveVectorW + effectiveGraphW;
- if (totalW > 0) {
- effectiveBm25W /= totalW;
- effectiveVectorW /= totalW;
- effectiveGraphW /= totalW;
- }
+ // Normalize once per query by the best attainable weighted score over
+ // the streams that produced results, so configured stream weights
+ // survive for single-stream hits and a silent stream carries no penalty.
+ const AGREEMENT_BONUS = 0.05;
+ const activeWeight =
+ (bm25Results.length > 0 ? this.bm25Weight : 0) +
+ (vectorResults.length > 0 ? this.vectorWeight : 0) +
+ (graphResults.length > 0 ? this.graphWeight : 0);
+ const maxAttainable = activeWeight * (1 / (RRF_K + 1));
+ const ranked = Array.from(scores.entries()).map(([obsId, s]) => {
+ const wB = Number.isFinite(s.bm25Rank) ? this.bm25Weight : 0;
+ const wV = Number.isFinite(s.vectorRank) ? this.vectorWeight : 0;
+ const wG = Number.isFinite(s.graphRank) ? this.graphWeight : 0;
+ const matchedStreams =
+ (wB > 0 ? 1 : 0) + (wV > 0 ? 1 : 0) + (wG > 0 ? 1 : 0);
+ const weighted =
+ wB * (1 / (RRF_K + s.bm25Rank)) +
+ wV * (1 / (RRF_K + s.vectorRank)) +
+ wG * (1 / (RRF_K + s.graphRank));
+ const rrf = maxAttainable > 0 ? weighted / maxAttainable : 0;
+ return {
+ obsId,
+ s,
+ combinedScore: rrf * (1 + AGREEMENT_BONUS * (matchedStreams - 1)),
+ minRank: Math.min(s.bm25Rank, s.vectorRank, s.graphRank),
+ };
+ });
- const combined = Array.from(scores.entries()).map(([obsId, s]) => ({
+ ranked.sort(
+ (a, b) =>
+ b.combinedScore - a.combinedScore ||
+ a.minRank - b.minRank ||
+ (a.obsId < b.obsId ? -1 : a.obsId > b.obsId ? 1 : 0),
+ );
+ const combined = ranked.map(({ obsId, s, combinedScore }) => ({
obsId,
sessionId: s.sessionId,
bm25Score: s.bm25Score,
vectorScore: s.vectorScore,
graphScore: s.graphScore,
graphContext: s.graphContext,
- combinedScore:
- effectiveBm25W * (1 / (RRF_K + s.bm25Rank)) +
- effectiveVectorW * (1 / (RRF_K + s.vectorRank)) +
- effectiveGraphW * (1 / (RRF_K + s.graphRank)),
+ combinedScore,
}));
- combined.sort((a, b) => b.combinedScore - a.combinedScore);
-
const retrievalDepth = Math.max(limit, 20);
const rerankWindow = 20;
const diversified = this.diversifyBySession(combined, retrievalDepth);
diff --git a/src/state/memory-utils.ts b/src/state/memory-utils.ts
index aa0bcc5b8..9bc5b16af 100644
--- a/src/state/memory-utils.ts
+++ b/src/state/memory-utils.ts
@@ -1,4 +1,4 @@
-import type { CompressedObservation, Memory } from "../types.js";
+import type { CompressedObservation, Lesson, Memory } from "../types.js";
// Wraps a Memory record in the CompressedObservation shape that
// SearchIndex / VectorIndex / enrichment paths consume. Memories share
@@ -20,5 +20,27 @@ export function memoryToObservation(memory: Memory): CompressedObservation {
concepts: memory.concepts,
files: memory.files,
importance: memory.strength,
+ // Carry the owning agent through so agent-scoped search filters see
+ // memories, not just raw observations. Dropping it made every memory
+ // invisible to any agentId-scoped query.
+ ...(memory.agentId ? { agentId: memory.agentId } : {}),
+ };
+}
+
+// Same adapter for lessons, kept beside memoryToObservation so a new
+// CompressedObservation field has one obvious place to be threaded
+// through both record kinds.
+export function lessonToObservation(l: Lesson): CompressedObservation {
+ return {
+ id: l.id,
+ sessionId: "lesson",
+ timestamp: l.createdAt,
+ type: "decision",
+ title: l.content.slice(0, 120),
+ facts: [l.content],
+ narrative: l.context || "",
+ concepts: l.tags,
+ files: [],
+ importance: l.confidence,
};
}
diff --git a/src/triggers/api.ts b/src/triggers/api.ts
index 7560e873d..56fad4f0d 100644
--- a/src/triggers/api.ts
+++ b/src/triggers/api.ts
@@ -23,6 +23,7 @@ import {
detectLlmProviderKind,
getAgentId,
isAgentScopeIsolated,
+ loadConfig,
} from "../config.js";
type Response = {
@@ -164,10 +165,23 @@ export function registerApiTriggers(
},
);
+ // Shared instance metadata for livez and health so the two never
+ // drift. streamsPort lets the viewer resolve its stream WebSocket
+ // target from the server instead of port arithmetic, which broke
+ // whenever the viewer bound a fallback port. Config is boot-static,
+ // so read it once instead of rebuilding the merged env per request.
+ const bootStreamsPort = loadConfig().streamsPort;
+ const instanceInfo = () => ({
+ service: "agentmemory",
+ viewerPort: getBoundViewerPort(),
+ viewerSkipped: getViewerSkipped(),
+ streamsPort: bootStreamsPort,
+ });
+
sdk.registerFunction("api::liveness",
async (): Promise => ({
status_code: 200,
- body: { status: "ok", service: "agentmemory", viewerPort: getBoundViewerPort(), viewerSkipped: getViewerSkipped() },
+ body: { status: "ok", ...instanceInfo() },
}),
);
sdk.registerTrigger({
@@ -268,8 +282,7 @@ export function registerApiTriggers(
health: health || null,
functionMetrics,
circuitBreaker,
- viewerPort: getBoundViewerPort(),
- viewerSkipped: getViewerSkipped(),
+ ...instanceInfo(),
},
};
},
@@ -1002,6 +1015,7 @@ export function registerApiTriggers(
ttlDays?: number;
sourceObservationIds?: string[];
project?: string;
+ agentId?: string;
}>,
): Promise => {
const authErr = checkAuth(req, secret);
@@ -1029,6 +1043,9 @@ export function registerApiTriggers(
...(req.body.ttlDays !== undefined && { ttlDays: req.body.ttlDays }),
...(req.body.sourceObservationIds !== undefined && { sourceObservationIds: req.body.sourceObservationIds }),
...(req.body.project !== undefined && { project: req.body.project }),
+ ...(typeof req.body.agentId === "string" && req.body.agentId.trim()
+ ? { agentId: req.body.agentId.trim() }
+ : {}),
},
});
return { status_code: 201, body: result };
diff --git a/src/triggers/events.ts b/src/triggers/events.ts
index 65db70351..bbf15db33 100644
--- a/src/triggers/events.ts
+++ b/src/triggers/events.ts
@@ -7,7 +7,6 @@ import {
getAgentId,
getConsolidationCooldownMs,
isConsolidationEnabled,
- isGraphExtractionEnabled,
} from "../config.js";
import { logger } from "../logger.js";
@@ -108,25 +107,20 @@ export function registerEventTriggers(sdk: ISdk, kv: StateKV): void {
if (isReflectEnabled()) {
fireVoid("mem::slot-reflect", { sessionId: data.sessionId });
}
- if (isGraphExtractionEnabled()) {
- try {
- const observations = await kv.list(
- KV.observations(data.sessionId),
- );
- const compressed = observations.filter((o) => o.title);
- if (compressed.length > 0) {
- sdk.trigger({
- function_id: "mem::graph-extract",
- payload: { observations: compressed },
- action: TriggerAction.Void(),
- });
- }
- } catch (err) {
- logger.warn("graph-extract trigger failed", {
- sessionId: data.sessionId,
- error: err instanceof Error ? err.message : String(err),
- });
+ // Unconditional: mem::graph-extract gates its LLM pass internally.
+ try {
+ const observations = await kv.list(
+ KV.observations(data.sessionId),
+ );
+ const compressed = observations.filter((o) => o.title);
+ if (compressed.length > 0) {
+ fireVoid("mem::graph-extract", { observations: compressed });
}
+ } catch (err) {
+ logger.warn("graph-extract trigger failed", {
+ sessionId: data.sessionId,
+ error: err instanceof Error ? err.message : String(err),
+ });
}
// Crystals + lessons consolidation. The stop lifecycle is the single
// source of truth: event::session::stopped fires for ALL agents (the
diff --git a/src/types.ts b/src/types.ts
index 2f3f0285f..1118b3f99 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -27,6 +27,23 @@ export interface CommitLink {
linkedAt: string;
}
+// Immutable write-time provenance: which trust boundary the content
+// crossed, inherited by derived records.
+export interface Origin {
+ channel: "user" | "agent" | "tool" | "import" | "shared";
+ detail?: string;
+ capturedAt: string;
+}
+
+export function importOrigin(
+ existing: Origin | undefined,
+ capturedAt: string,
+ detail?: string,
+): Origin {
+ if (existing) return existing;
+ return { channel: "import", capturedAt, ...(detail ? { detail } : {}) };
+}
+
export interface RawObservation {
id: string;
sessionId: string;
@@ -41,6 +58,7 @@ export interface RawObservation {
modality?: "text" | "image" | "mixed";
imageData?: string;
agentId?: string;
+ origin?: Origin;
}
export interface CompressedObservation {
@@ -61,6 +79,7 @@ export interface CompressedObservation {
imageDescription?: string;
modality?: "text" | "image" | "mixed";
agentId?: string;
+ origin?: Origin;
}
export type ObservationType =
@@ -102,6 +121,7 @@ export interface Memory {
imageData?: string;
agentId?: string;
project?: string;
+ origin?: Origin;
}
export interface SessionSummary {
diff --git a/src/viewer/favicon.svg b/src/viewer/favicon.svg
index 3ef799f78..68b00c109 100644
--- a/src/viewer/favicon.svg
+++ b/src/viewer/favicon.svg
@@ -1 +1,35 @@
-AM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/viewer/index.html b/src/viewer/index.html
index 3efe43425..de47f4f5f 100644
--- a/src/viewer/index.html
+++ b/src/viewer/index.html
@@ -50,22 +50,21 @@
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
}
html[data-theme="dark"] {
- --bg: #1a1a1e;
- --bg-alt: #232328;
- --bg-subtle: #1f1f24;
- --bg-inset: #2a2a30;
- --border: #444;
- --border-light: #3a3a42;
- --border-heavy: #ccc;
- --ink: #eee;
- --ink-secondary: #ccc;
- --ink-muted: #999;
- --ink-faint: #777;
+ --bg: #121316;
+ --bg-alt: #1a1c20;
+ --bg-subtle: #17181b;
+ --bg-inset: #222428;
+ --border: #33363b;
+ --border-light: #26282c;
+ --border-heavy: #c9cbd1;
+ --ink: #eef0f3;
+ --ink-secondary: #c6c9ce;
+ --ink-muted: #94979d;
+ --ink-faint: #6d7076;
+ --accent: #f2555a;
+ --accent-light: #ff7a70;
--cream: #2a2520;
}
- html[data-theme="dark"] body {
- background-image: radial-gradient(circle, #3a3a42 0.5px, transparent 0.5px);
- }
html[data-theme="dark"] .graph-tooltip {
background: rgba(30,30,35,0.92);
border-color: rgba(255,255,255,0.1);
@@ -80,6 +79,16 @@
color: var(--bg);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
+ #bg-dither {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 0;
+ pointer-events: none;
+ opacity: 0.5;
+ }
+ .app-header, .tab-bar, .view, .flags-banner, footer, .app-footer { position: relative; z-index: 1; }
body {
font-family: var(--font-body);
background: var(--bg);
@@ -89,8 +98,6 @@
height: 100vh;
display: flex;
flex-direction: column;
- background-image: radial-gradient(circle, #D4D4CF 0.5px, transparent 0.5px);
- background-size: 16px 16px;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
@@ -139,6 +146,11 @@
align-items: center;
gap: 12px;
}
+ @media (max-width: 720px) {
+ .app-header { flex-wrap: wrap; row-gap: 6px; padding: 10px 16px; }
+ .app-header .dateline { display: none; }
+ .view { overflow-x: auto; }
+ }
.ws-status {
font-size: 10px;
padding: 3px 10px;
@@ -158,7 +170,11 @@
display: inline-block;
}
.ws-status.connected { border-color: var(--green); color: var(--green); }
- .ws-status.connected::before { background: var(--green); }
+ .ws-status.connected::before { background: var(--green); animation: live-pulse 2.4s ease-in-out infinite; }
+ @keyframes live-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+ }
.ws-status.disconnected { border-color: var(--ink-faint); color: var(--ink-faint); }
.ws-status.disconnected::before { background: var(--ink-faint); }
@@ -195,7 +211,15 @@
}
.view { display: none; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 24px; }
- .view.active { display: block; }
+ .view.active { display: block; animation: view-in 160ms ease-out; }
+ @keyframes view-in {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .view.active { animation: none; }
+ .ws-status.connected::before { animation: none; }
+ }
.stats-grid {
display: grid;
@@ -211,6 +235,14 @@
border-bottom: 1px solid var(--border-light);
}
.stat-card:last-child { border-right: none; }
+ .stat-card[data-action] {
+ cursor: pointer;
+ transition: background 0.15s ease-out;
+ }
+ .stat-card[data-action]:hover { background: var(--bg-alt); }
+ .stat-card[data-action]:hover .label { color: var(--accent); }
+ .stat-card[data-action]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+ .stat-card[data-action]:active { background: var(--bg-inset); }
.stat-card .label {
font-size: 9px;
color: var(--ink-muted);
@@ -294,6 +326,7 @@
border-collapse: collapse;
font-size: 13px;
font-family: var(--font-body);
+ font-variant-numeric: tabular-nums;
}
th {
text-align: left;
@@ -333,7 +366,7 @@
align-items: center;
flex-wrap: wrap;
}
- .toolbar input, .toolbar select {
+ .toolbar input, .toolbar select, .search-input {
background: var(--bg);
border: 1px solid var(--border);
color: var(--ink);
@@ -342,13 +375,13 @@
outline: none;
font-family: var(--font-ui);
}
- .toolbar input:focus, .toolbar select:focus {
+ .toolbar input:focus, .toolbar select:focus, .search-input:focus {
border-color: var(--ink);
box-shadow: 2px 2px 0px 0px var(--border);
}
.toolbar input { flex: 1; min-width: 200px; }
- .btn {
+ .btn, .toolbar button {
background: var(--bg);
border: 1px solid var(--border);
color: var(--ink);
@@ -361,8 +394,8 @@
text-transform: uppercase;
letter-spacing: 0.06em;
}
- .btn:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); }
- .btn:active { box-shadow: none; transform: translate(0, 0); }
+ .btn:hover, .toolbar button:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); }
+ .btn:active, .toolbar button:active { box-shadow: none; transform: translate(0, 0); }
.btn-danger { border-color: var(--accent); color: var(--accent); }
.btn-danger:hover { background: var(--accent); color: white; box-shadow: 3px 3px 0px 0px var(--border); }
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
@@ -370,7 +403,8 @@
.graph-container {
display: flex;
- height: calc(100vh - 130px);
+ height: calc(100vh - 178px);
+ min-height: 460px;
margin: -24px;
border-top: 1px solid var(--border-light);
}
@@ -523,18 +557,36 @@
}
.tag.file-tag { border-color: var(--green); color: var(--green); }
+ /* Two-pane sessions: list left, detail pinned right on wide screens.
+ The detail panel previously rendered below the full list — selecting
+ a session on any real corpus put the response off-screen. */
+ .sessions-layout {
+ display: grid;
+ grid-template-columns: minmax(300px, 400px) minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+ }
+ .sessions-layout #session-detail { position: sticky; top: 0; min-width: 0; }
+ .sessions-layout #session-detail .detail-panel { margin-top: 0; }
+ @media (max-width: 1100px) {
+ .sessions-layout { grid-template-columns: 1fr; }
+ .sessions-layout #session-detail { position: static; }
+ }
.session-list { display: flex; flex-direction: column; gap: 0; }
.session-item {
background: var(--bg);
border: 1px solid var(--border-light);
border-bottom: none;
+ border-left: 3px solid transparent;
padding: 14px 20px;
cursor: pointer;
- transition: background 0.1s;
+ transition: background 0.15s ease-out, border-color 0.15s ease-out;
}
.session-item:last-child { border-bottom: 1px solid var(--border-light); }
- .session-item:hover { background: var(--bg-alt); }
- .session-item.selected { background: var(--bg-alt); border-left: 3px solid var(--accent); }
+ .session-item:hover { background: var(--bg-alt); border-left-color: var(--border-light); }
+ .session-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+ .session-item:active { background: var(--bg-inset); }
+ .session-item.selected { background: var(--bg-alt); border-left-color: var(--accent); }
.session-item .session-top {
display: flex;
justify-content: space-between;
@@ -975,6 +1027,7 @@
+
';
}
html += '';
- html += '
Sessions
' + d.sessions.length + '
' + activeSessions + ' active
';
- html += '
Memories
' + d.memories.length + '
latest versions
';
+ html += '
Sessions
' + d.sessions.length + '
' + activeSessions + ' active
';
+ html += '
Memories
' + d.memories.length + '
latest versions
';
var lessonCount = (d.lessons || []).length;
var crystalCount = (d.crystals || []).length;
- html += '
Lessons
' + lessonCount + '
confidence-scored
';
- html += '
Crystals
' + crystalCount + '
action digests
';
- html += '
Graph Nodes
' + nodeCount + '
' + edgeCount + ' edges
';
+ html += '
Lessons
' + lessonCount + '
confidence-scored
';
+ html += '
Crystals
' + crystalCount + '
action digests
';
+ html += '
Graph Nodes
' + nodeCount + '
' + edgeCount + ' edges
';
html += '
Health
' + esc(healthStatus) + '
';
html += '
' + esc(snap.connectionState || 'unknown') + '
';
var totalCalls = fMetrics.reduce(function(a, m) { return a + (m.totalCalls || 0); }, 0);
@@ -1495,7 +1610,8 @@
agentmemory
if (snap.alerts && snap.alerts.length > 0) {
html += '
Alerts (' + snap.alerts.length + ')
';
- snap.alerts.forEach(function(al) {
+ snap.alerts.forEach(function(alRaw) {
+ var al = humanizeHealthFlag(alRaw);
html += '
' + esc(al) + '
';
});
html += '
';
@@ -1504,7 +1620,7 @@
agentmemory
if (snap.notes && snap.notes.length > 0) {
html += '
Notes (' + snap.notes.length + ')
';
snap.notes.forEach(function(n) {
- html += '
' + esc(n) + '
';
+ html += '
' + esc(humanizeHealthFlag(n)) + '
';
});
html += '
';
}
@@ -1638,6 +1754,9 @@
agentmemory
html += '
Semantic facts ' + semFacts.length + '
';
html += '
Procedures ' + procItems.length + '
';
html += '
Relations ' + relItems.length + '
';
+ if (semFacts.length === 0 && procItems.length === 0 && relItems.length === 0) {
+ html += '
Consolidation distills session observations into durable facts and repeatable procedures. It runs on a schedule when CONSOLIDATION_ENABLED=true and an LLM provider key are set, or on demand via memory_consolidate.
';
+ }
html += '
';
if (relItems.length > 0) {
@@ -1695,9 +1814,24 @@ agentmemory
var results = await Promise.all([
apiPost('graph/query', { limit: GRAPH_INITIAL_LIMIT }),
- apiGet('graph/stats')
+ api('graph/stats', { readErrorBody: true })
]);
var queryResult = results[0];
+ var statsResult = results[1];
+ if (statsResult && statsResult.error && statsResult.flag) {
+ // 503 with a structured body = the feature is off, not broken.
+ // Rendering this as "query failed / Retry" sends users hunting
+ // through server logs for an error that isn't one.
+ state.graph.disabledInfo = statsResult;
+ state.graph.queryError = null;
+ state.graph.nodes = [];
+ state.graph.edges = [];
+ state.graph.stats = {};
+ state.graph.loaded = true;
+ renderGraphSidebar();
+ return;
+ }
+ state.graph.disabledInfo = null;
if (queryResult === null) {
// api() returns null only on non-2xx or a transport error; an
// empty graph would come back as { nodes: [], edges: [] }.
@@ -1764,6 +1898,17 @@ agentmemory
var html = '';
+ if (state.graph.disabledInfo) {
+ html += '';
+ html += '
Knowledge graph is off
';
+ html += '
' + esc(state.graph.disabledInfo.enableHow || 'Set ' + (state.graph.disabledInfo.flag || 'GRAPH_EXTRACTION_ENABLED') + '=true and restart.') + '
';
+ if (state.graph.disabledInfo.docsHref) {
+ html += '
docs → ';
+ }
+ html += '
';
+ sb.innerHTML = html;
+ return;
+ }
// #753: error banner stays above the search box so a failed
// graph/query doesn't read as "0 nodes".
if (state.graph.queryError) {
@@ -1796,7 +1941,10 @@ agentmemory
html += ' ' + esc(type) + ' ';
});
- html += 'Legend ';
+ if (state.graph.nodes.length > 0 && state.graph.edges.length === 0) {
+ html += '
Entities extracted, no relations between them yet. Nodes are grouped by kind; edges appear as extraction sees entities acting on each other across more sessions (larger models find them faster).
';
+ }
+ html += '
Legend ';
var shapeLabels = { rect: '▭', circle: '●', diamond: '◆', hexagon: '⬢' };
var shownShapes = {};
Object.keys(NODE_COLORS).forEach(function(type) {
@@ -1856,21 +2004,38 @@
agentmemory
edgeMap[e.targetNodeId] = (edgeMap[e.targetNodeId] || 0) + 1;
});
+ // With no (or few) edges a pure force layout is a meaningless
+ // scatter. Anchor each type to its own cluster center so the
+ // picture reads as "here is what memory knows, grouped by kind";
+ // once real relations exist the edge springs dominate instead.
+ var types = [];
+ state.graph.nodes.forEach(function(n) {
+ if (types.indexOf(n.type) === -1) types.push(n.type);
+ });
+ var typeCenters = {};
+ types.forEach(function(t, ti) {
+ var a = (2 * Math.PI * ti) / Math.max(types.length, 1) - Math.PI / 2;
+ var cr = types.length > 1 ? Math.min(cw, ch) * 0.28 : 0;
+ typeCenters[t] = { x: Math.cos(a) * cr, y: Math.sin(a) * cr };
+ });
+ graphSim.typeCenters = typeCenters;
+ graphSim.clustered = state.graph.edges.length < state.graph.nodes.length / 2;
+
graphSim.nodes = state.graph.nodes.map(function(n, i) {
- var angle = (2 * Math.PI * i) / Math.max(state.graph.nodes.length, 1);
- var radius = Math.min(cw, ch) * 0.3;
var deg = edgeMap[n.id] || 0;
+ var c = typeCenters[n.type] || { x: 0, y: 0 };
return {
id: n.id, type: n.type, name: n.name, properties: n.properties,
- x: Math.cos(angle) * radius + (Math.random() - 0.5) * 50,
- y: Math.sin(angle) * radius + (Math.random() - 0.5) * 50,
+ x: c.x + (Math.random() - 0.5) * 150,
+ y: c.y + (Math.random() - 0.5) * 150,
vx: 0, vy: 0,
- r: Math.max(8, Math.min(22, 8 + deg * 2.5))
+ r: Math.max(11, Math.min(22, 11 + deg * 2.5))
};
});
graphSim.edges = state.graph.edges.slice();
graphSim.running = true;
graphSim.dragNode = null;
+ graphSim.autoFitPending = true;
setupGraphInteraction(canvas);
runSimulation();
@@ -2084,8 +2249,14 @@
agentmemory
fx += (dx / dist) * force;
fy += (dy / dist) * force;
}
- fx -= n.x * centerGravity;
- fy -= n.y * centerGravity;
+ if (graphSim.clustered && graphSim.typeCenters && graphSim.typeCenters[n.type]) {
+ var tc = graphSim.typeCenters[n.type];
+ fx += (tc.x - n.x) * 0.006;
+ fy += (tc.y - n.y) * 0.006;
+ } else {
+ fx -= n.x * centerGravity;
+ fy -= n.y * centerGravity;
+ }
var nvx = (n.vx + fx) * damping;
var nvy = (n.vy + fy) * damping;
// Velocity cap (#563): keep any single node from being launched
@@ -2118,6 +2289,10 @@
agentmemory
totalKineticEnergy += n.vx * n.vx + n.vy * n.vy;
});
+ if (graphSim.autoFitPending && graphSim.tickCount > 45) {
+ graphSim.autoFitPending = false;
+ fitGraphToView();
+ }
// park the simulation when the layout is quiet to save CPU.
// Pick up again when a drag/interaction wakes the loop.
var rmsVelocity = nodes.length > 0 ? Math.sqrt(totalKineticEnergy / nodes.length) : 0;
@@ -2175,6 +2350,27 @@
agentmemory
}
}
+ function fitGraphToView() {
+ var canvas = graphSim.canvas;
+ if (!canvas || graphSim.nodes.length === 0) return;
+ var w = canvas.width / window.devicePixelRatio;
+ var h = canvas.height / window.devicePixelRatio;
+ var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ graphSim.nodes.forEach(function(n) {
+ if (n.x < minX) minX = n.x;
+ if (n.x > maxX) maxX = n.x;
+ if (n.y < minY) minY = n.y;
+ if (n.y > maxY) maxY = n.y;
+ });
+ var pad = 150;
+ var spanX = (maxX - minX) + pad * 2;
+ var spanY = (maxY - minY) + pad * 2;
+ var z = Math.min(w / spanX, h / spanY);
+ graphSim.zoom = Math.max(0.35, Math.min(1.4, z));
+ graphSim.panX = w / 2 - ((minX + maxX) / 2) * graphSim.zoom;
+ graphSim.panY = h / 2 - ((minY + maxY) / 2) * graphSim.zoom;
+ }
+
function renderGraph() {
var ctx = graphSim.ctx;
var canvas = graphSim.canvas;
@@ -2205,6 +2401,15 @@
agentmemory
graphSim.nodes.forEach(function(n) { nodeMap[n.id] = n; });
var searchActive = graphSearchTerm.length > 0;
+ var drawnLabelRects = [];
+ function labelFits(x, y, w2, h2) {
+ for (var li = 0; li < drawnLabelRects.length; li++) {
+ var r = drawnLabelRects[li];
+ if (x < r.x + r.w && x + w2 > r.x && y < r.y + r.h && y + h2 > r.y) return false;
+ }
+ drawnLabelRects.push({ x: x, y: y, w: w2, h: h2 });
+ return true;
+ }
var totalVisible = graphSim.nodes.filter(function(n) { return state.graph.filters[n.type]; }).length;
var isDense = totalVisible > 40;
var labelZoomThreshold = isDense ? 1.5 : 0.5;
@@ -2303,7 +2508,7 @@
agentmemory
var isSelected = selectedId === n.id;
var isHovered = hoverNodeId === n.id;
var matchesSearch = !searchActive || n.name.toLowerCase().includes(graphSearchTerm);
- var isFocusFaded = focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) {
+ var isFocusFaded = graphSim.edges.length > 0 && focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) {
return (ed.sourceNodeId === focusNodeId && ed.targetNodeId === n.id) ||
(ed.targetNodeId === focusNodeId && ed.sourceNodeId === n.id);
});
@@ -2356,6 +2561,7 @@
agentmemory
var showLabel = matchesSearch && !isFocusFaded && (
isSelected || isHovered ||
(searchActive && matchesSearch) ||
+ (totalVisible <= 30 && graphSim.zoom > 0.5) ||
(!isDense && graphSim.zoom > labelZoomThreshold) ||
(isDense && graphSim.zoom > labelZoomThreshold && n.r > 10)
);
@@ -2370,7 +2576,13 @@
agentmemory
var labelW = textW + (16 * zoomInv);
var labelH = 20 * zoomInv;
var labelY = n.y + n.r + (8 * zoomInv); // Top of the background pill
-
+ // Selected/hovered labels always win; others draw only into
+ // free space so zoomed-out views degrade to fewer labels
+ // instead of a pile of overlapping pills.
+ if (!isSelected && !isHovered && !labelFits(n.x - labelW / 2, labelY, labelW, labelH)) {
+ ctx.restore();
+ return;
+ }
ctx.fillStyle = isDarkMode() ? 'rgba(30,30,35,0.92)' : 'rgba(255,255,255,0.92)';
ctx.beginPath();
ctx.roundRect ? ctx.roundRect(n.x - labelW / 2, labelY, labelW, labelH, 4 * zoomInv) : ctx.rect(n.x - labelW / 2, labelY, labelW, labelH);
@@ -2401,7 +2613,7 @@
agentmemory
async function loadMemories() {
var el = document.getElementById('view-memories');
- el.innerHTML = '
Loading memories...
';
+ if (!state.memories.loaded) el.innerHTML = '
Loading memories...
';
// cap at 2000 so the viewer remains responsive on large
// corpora. Older endpoints returned the full unbounded list which
// hit the iii invocation timeout and the UI fell through to 0.
@@ -2484,7 +2696,8 @@
agentmemory
var strength = Math.round(rawStrength <= 1 ? rawStrength * 100 : rawStrength * 10);
if (strength > 100) strength = 100;
var barColor = strength > 70 ? 'var(--green)' : strength > 40 ? 'var(--yellow)' : 'var(--red)';
- html += '
';
+ var expanded = state.memories.selectedId === m.id;
+ html += ' ';
var preview = (m.content || '').split('\n').slice(0, 2).join(' ').trim();
var previewHtml = esc(truncate(preview, 150));
if (search && search.length > 2) {
@@ -2505,6 +2718,24 @@ agentmemory
html += '' + esc(formatTime(m.updatedAt)) + ' ';
html += 'Delete ';
html += ' ';
+ if (expanded) {
+ html += '
';
+ html += '' + esc(m.content || '') + '
';
+ html += '';
+ html += 'id: ' + esc(m.id) + ' ';
+ if (m.origin && m.origin.channel) {
+ html += 'origin: ' + esc(m.origin.channel) + (m.origin.detail ? ' (' + esc(m.origin.detail) + ')' : '') + ' ';
+ }
+ if (m.project) html += 'project: ' + esc(m.project) + ' ';
+ if (m.createdAt) html += 'created: ' + esc(formatTime(m.createdAt)) + ' ';
+ if (m.supersedes && m.supersedes.length > 0) html += 'supersedes: ' + esc(m.supersedes.join(', ')) + ' ';
+ if (m.files && m.files.length > 0) html += 'files: ' + esc(m.files.join(', ')) + ' ';
+ if (m.sessionIds && m.sessionIds.length > 0) html += 'sessions: ' + m.sessionIds.length + ' ';
+ html += '
';
+ html += 'raw record ';
+ html += '' + esc(JSON.stringify(m, null, 2)) + ' ';
+ html += ' ';
+ }
});
html += '';
}
@@ -2546,13 +2777,19 @@
agentmemory
async function loadTimeline() {
var el = document.getElementById('view-timeline');
- el.innerHTML = '
Loading timeline...
';
+ if (!state.timeline.loaded) el.innerHTML = '
Loading timeline...
';
var sessResult = await apiGet('sessions');
var sessions = (sessResult && sessResult.sessions) || [];
state.timeline.loaded = true;
if (sessions.length > 0 && !state.timeline.sessionId) {
- var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
+ // Default to the session with the most observations — the newest
+ // one is often a sparse just-started session, which made the tab
+ // look empty on first open.
+ var sorted = sessions.slice().sort(function(a, b) {
+ return (b.observationCount || 0) - (a.observationCount || 0) ||
+ (b.startedAt || '').localeCompare(a.startedAt || '');
+ });
var firstSelectable = sorted.find(function(s) { return sessionId(s); });
state.timeline.sessionId = firstSelectable ? sessionId(firstSelectable) : '';
}
@@ -2692,7 +2929,7 @@
agentmemory
html += '
' + esc(shortTime(o.timestamp)) + ' ';
html += '
';
- if (o.subtitle) html += '' + esc(o.subtitle) + '
';
+ if (o.subtitle) html += '' + esc(humanizeSubtitle(o.subtitle)) + '
';
html += '';
html += '
' + esc(type.replace(/_/g, ' ')) + ' ';
@@ -2772,7 +3009,7 @@
agentmemory
async function loadActivity() {
var el = document.getElementById('view-activity');
- el.innerHTML = '
Loading activity...
';
+ if (!state.activity.loaded) el.innerHTML = '
Loading activity...
';
var results = await Promise.all([
apiGet('sessions'),
apiGet('audit?limit=200')
@@ -2909,7 +3146,7 @@
agentmemory
async function loadSessions() {
var el = document.getElementById('view-sessions');
- el.innerHTML = '
Loading sessions...
';
+ if (!state.sessions.loaded) el.innerHTML = '
Loading sessions...
';
var result = await apiGet('sessions');
state.sessions.items = (result && result.sessions) || [];
state.sessions.loaded = true;
@@ -2922,7 +3159,7 @@
agentmemory
return (b.startedAt || '').localeCompare(a.startedAt || '');
});
- var html = '
';
+ var html = '
';
if (items.length === 0) {
html += '
';
} else {
@@ -2930,7 +3167,7 @@
agentmemory
var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted';
var id = sessionId(s);
var selected = id && state.sessions.selectedId === id;
- html += '
';
+ html += '
';
html += '
' + esc(sessionDisplayName(s)) + ' ';
html += '' + esc(s.status) + '
';
var preview = s.firstPrompt || s.summary || '';
@@ -2944,7 +3181,7 @@
agentmemory
});
}
html += '
';
- html += '
';
+ html += '
';
el.innerHTML = html;
if (state.sessions.selectedId) renderSessionDetail();
@@ -2953,6 +3190,18 @@
agentmemory
function selectSession(id) {
state.sessions.selectedId = state.sessions.selectedId === id ? null : id;
renderSessions();
+ // On the stacked layout (narrow screens) the detail renders below
+ // the list; bring it into view. The wide two-pane layout keeps the
+ // panel sticky beside the list, so no scroll is needed there.
+ if (
+ state.sessions.selectedId &&
+ window.matchMedia('(max-width: 1100px)').matches
+ ) {
+ var panel = document.getElementById('session-detail');
+ if (panel && panel.scrollIntoView) {
+ panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }
}
async function renderSessionDetail() {
@@ -3076,7 +3325,7 @@
agentmemory
async function loadLessons() {
var el = document.getElementById('view-lessons');
- el.innerHTML = '
Loading lessons...
';
+ if (!state.lessons.loaded) el.innerHTML = '
Loading lessons...
';
var result = await apiGet('lessons');
state.lessons.items = (result && result.lessons) || [];
state.lessons.loaded = true;
@@ -3108,16 +3357,17 @@
agentmemory
html += '
' +
'
💡
' +
'
No lessons yet
' +
- '
Lessons are confidence-scored pattern observations — things you corrected once that the agent should never do again. They persist across projects.
' +
+ '
Lessons are short imperative rules (always/never/prefer/avoid) learned from past work — things you corrected once that the agent should never repeat. Confidence grows when they hold and decays when unused.
' +
'
# Save a lesson explicitly\nmemory_lesson_save { rule, reason, confidence }\n\n# Or: Replay tab → Import JSONL auto-extracts lessons\n# from your past Claude Code sessions ' +
'
' +
'
';
} else {
- html += '
Lesson Confidence Reinforcements Source Project Updated ';
+ html += 'Lesson Confidence Uses Source Project Updated ';
items.forEach(function(l) {
var confPct = Math.round(l.confidence * 100);
var confColor = confPct >= 70 ? 'var(--green)' : confPct >= 40 ? 'var(--yellow)' : 'var(--red)';
- html += '';
+ var expanded = state.lessons.selectedId === l.id;
+ html += ' ';
html += '' + esc(truncate(l.content, 120)) + (l.context ? '' + esc(truncate(l.context, 80)) + '
' : '') + ' ';
html += ' ';
html += '' + (l.reinforcements || 0) + ' ';
@@ -3125,6 +3375,21 @@ agentmemory
html += '' + esc(l.project || '-') + ' ';
html += '' + shortTime(l.updatedAt) + ' ';
html += ' ';
+ if (expanded) {
+ html += '';
+ html += '' + esc(l.content) + '
';
+ if (l.context) html += 'Why learned ' + esc(l.context) + '
';
+ html += '';
+ html += 'id: ' + esc(l.id) + ' ';
+ if (l.tags && l.tags.length) html += 'tags: ' + esc(l.tags.join(', ')) + ' ';
+ if (l.createdAt) html += 'learned: ' + esc(formatTime(l.createdAt)) + ' ';
+ if (l.lastReinforcedAt) html += 'last confirmed: ' + esc(formatTime(l.lastReinforcedAt)) + ' ';
+ if (l.sourceIds && l.sourceIds.length) html += 'from ' + l.sourceIds.length + ' session(s) ';
+ html += '
';
+ html += 'raw record ';
+ html += '' + esc(JSON.stringify(l, null, 2)) + ' ';
+ html += ' ';
+ }
});
html += '
';
}
@@ -3138,7 +3403,7 @@ agentmemory
async function loadActions() {
var el = document.getElementById('view-actions');
- el.innerHTML = 'Loading actions...
';
+ if (!state.actions.loaded) el.innerHTML = 'Loading actions...
';
var results = await Promise.all([apiGet('actions'), apiGet('frontier')]);
state.actions.items = (results[0] && results[0].actions) || [];
state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || [];
@@ -3149,6 +3414,10 @@ agentmemory
function renderActions() {
var el = document.getElementById('view-actions');
var items = state.actions.items;
+ var introCard = '' +
+ '
' +
+ 'Actions are follow-ups the agent surfaced during sessions — decisions to revisit, files to inspect, tasks blocked on input. Status flows pending → active → done/blocked; the frontier marks what is unblocked and ready to pick up next.' +
+ '
';
var search = state.actions.search.toLowerCase();
var statusFilter = state.actions.statusFilter;
var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; }));
@@ -3162,7 +3431,8 @@ agentmemory
items = items.filter(function(a) { return a.status === statusFilter; });
}
- var html = '';
+ var html = introCard;
+ html += '
';
html += '
';
html += '
';
html += 'All statuses ';
@@ -3213,7 +3483,7 @@ agentmemory
async function loadCrystals() {
var el = document.getElementById('view-crystals');
- el.innerHTML = 'Loading crystals...
';
+ if (!state.crystals.loaded) el.innerHTML = 'Loading crystals...
';
var results = await Promise.all([apiGet('crystals'), apiGet('lessons')]);
state.crystals.items = (results[0] && results[0].crystals) || [];
var lessonMap = {};
@@ -3264,7 +3534,7 @@ agentmemory
html += '' +
'
💎
' +
'
No crystals yet
' +
- '
Crystals are compressed action digests — the 3-line summary of what happened in a session. Generated from long conversations to give the next session fast context without re-reading everything.
' +
+ '
Crystals are frozen snapshots of completed work — one session’s narrative, key outcomes, files touched, and lessons surfaced, kept after raw observations are pruned so the next session gets fast context.
' +
'
# Auto: import a JSONL transcript\n# Replay tab → Import JSONL\n\n# Manual: crystallize a specific session\nmemory_crystallize { sessionId } ' +
'
' +
'
';
@@ -3326,7 +3596,7 @@ agentmemory
async function loadAudit() {
var el = document.getElementById('view-audit');
- el.innerHTML = 'Loading audit log...
';
+ if (!state.audit.loaded) el.innerHTML = 'Loading audit log...
';
var result = await apiGet('audit?limit=100');
state.audit.entries = (result && result.entries) || [];
state.audit.loaded = true;
@@ -3450,7 +3720,7 @@ agentmemory
html += 'Top Concepts
';
var concepts = p.topConcepts || [];
if (concepts.length === 0) {
- html += '
No concepts yet
';
+ html += '
No concepts yet. Concepts are tagged when observations are compressed by an LLM (AGENTMEMORY_AUTO_COMPRESS=true + provider key) or attached to saved memories.
';
} else {
var maxC = Math.max.apply(null, concepts.map(function(c) { return c.frequency; })) || 1;
html += '
';
@@ -3859,6 +4129,17 @@
agentmemory
}
});
fetchFlags();
+ // Keyboard activation for role="button" cards (session items). Click
+ // delegation alone leaves them unreachable for keyboard and AT users.
+ document.addEventListener('keydown', function(e) {
+ if (e.key !== 'Enter' && e.key !== ' ') return;
+ if (!(e.target instanceof Element)) return;
+ var card = e.target.closest('[data-action][role="button"], [data-action][role="link"]');
+ if (!card) return;
+ e.preventDefault();
+ card.click();
+ });
+
document.addEventListener('click', function(e) {
if (!(e.target instanceof Element)) return;
var target = e.target.closest('[data-action]');
@@ -3939,6 +4220,27 @@ agentmemory
if (sessionId) selectSession(sessionId);
return;
}
+ if (action === 'goto-tab') {
+ var gotoTab = target.getAttribute('data-tab');
+ if (gotoTab) switchTab(gotoTab);
+ return;
+ }
+ if (action === 'select-lesson') {
+ var lsnId = target.getAttribute('data-lesson-id');
+ if (lsnId) {
+ state.lessons.selectedId = state.lessons.selectedId === lsnId ? null : lsnId;
+ renderLessons();
+ }
+ return;
+ }
+ if (action === 'select-memory') {
+ var memId = target.getAttribute('data-memory-id');
+ if (memId) {
+ state.memories.selectedId = state.memories.selectedId === memId ? null : memId;
+ renderMemories();
+ }
+ return;
+ }
if (action === 'end-session') {
var endSessionId = target.getAttribute('data-session-id');
if (endSessionId) endSession(endSessionId);
@@ -4188,8 +4490,83 @@ agentmemory
});
switchTab(tabFromRoute(), { replaceRoute: true });
- connectWs();
+ // Resolve the stream WebSocket target from the server before the first
+ // connect. The old viewerPort-1 arithmetic breaks whenever the viewer
+ // binds a fallback port (3113 taken → viewer on 3114 → 3113 is another
+ // HTTP server, not the streams endpoint) — every retry then fails and
+ // the viewer silently degrades to 10s polling.
+ (async function initWs() {
+ try {
+ var live = await api('livez', { signal: AbortSignal.timeout(5000) });
+ if (live && typeof live.streamsPort === 'number' && live.streamsPort > 0) {
+ WS_URL = wsProto + '//' + window.location.hostname + ':' + live.streamsPort;
+ WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer';
+ }
+ } catch (_) {}
+ connectWs();
+ })();
startDashboardAutoRefresh();
+
+ // Ambient background: an ordered-dither dot field that drifts very
+ // slowly — the print-halftone cousin of the old static dot grid.
+ // Renders at quarter resolution and ~12fps; a single static frame
+ // under prefers-reduced-motion.
+ (function ditherField() {
+ var cv = document.getElementById('bg-dither');
+ if (!cv || !cv.getContext) return;
+ var ctx = cv.getContext('2d');
+ var BAYER = [
+ [0, 8, 2, 10],
+ [12, 4, 14, 6],
+ [3, 11, 1, 9],
+ [15, 7, 13, 5]
+ ];
+ var CELL = 7;
+ var t = 0;
+ var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ function dotColor() {
+ return document.documentElement.getAttribute('data-theme') === 'dark'
+ ? 'rgba(150, 154, 162, 0.16)'
+ : 'rgba(90, 90, 84, 0.14)';
+ }
+
+ function draw() {
+ var w = cv.width = Math.ceil(window.innerWidth / 2);
+ var h = cv.height = Math.ceil(window.innerHeight / 2);
+ ctx.clearRect(0, 0, w, h);
+ ctx.fillStyle = dotColor();
+ var cols = Math.ceil(w / CELL) + 1;
+ var rows = Math.ceil(h / CELL) + 1;
+ for (var y = 0; y < rows; y++) {
+ for (var x = 0; x < cols; x++) {
+ // Slow luminance swells drifting diagonally; the Bayer
+ // threshold turns them into stable ordered-dither dots.
+ var v =
+ 0.5 +
+ 0.5 *
+ Math.sin(x * 0.11 + t * 0.013) *
+ Math.cos(y * 0.13 - t * 0.011);
+ if (v * 16 > BAYER[y % 4][x % 4] + 6.5) {
+ ctx.fillRect(x * CELL, y * CELL, 1.4, 1.4);
+ }
+ }
+ }
+ }
+
+ draw();
+ if (!reduced) {
+ setInterval(function () {
+ t++;
+ draw();
+ }, 85);
+ }
+ window.addEventListener('resize', draw);
+ new MutationObserver(draw).observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-theme']
+ });
+ })();