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 @@

- agentmemory — Persistent memory for AI coding agents + agentmemory: persistent memory for AI coding agents

@@ -30,7 +30,7 @@

- Design doc: 1.3k stars / 182 forks on the gist + Design doc: 1.6k stars / 230 forks on the gist

@@ -50,7 +50,7 @@ 54 MCP tools 12 auto hooks 0 external DBs - 1,596+ tests passing + 1,648+ tests passing

@@ -66,7 +66,6 @@ How It WorksMCPViewer • - iii ConsolePowered by iiiConfigAPI @@ -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. ---

Why agentmemory

-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

MCP Server

-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/

Real-Time Viewer

-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

iii Console

-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.

- iii console Workers page — connected workers including agentmemory instances with live function counts and runtime metadata + iii console Workers page: connected workers including agentmemory instances with live function counts and runtime metadata
- 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. ---

Powered by iii

-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 @@ +

agentmemory

@@ -1112,12 +1165,12 @@

agentmemory

activeTab: 'dashboard', dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [] }, graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null, queryError: null, truncated: false, totalNodes: 0, totalEdges: 0 }, - memories: { loaded: false, items: [], search: '', typeFilter: '' }, + memories: { loaded: false, items: [], search: '', typeFilter: '', selectedId: null }, timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 }, sessions: { loaded: false, items: [], selectedId: null }, audit: { loaded: false, entries: [], opFilter: '' }, activity: { loaded: false, observations: [], sessions: [], typeFilter: '' }, - lessons: { loaded: false, items: [], search: '' }, + lessons: { loaded: false, items: [], search: '', selectedId: null }, actions: { loaded: false, items: [], frontier: [], statusFilter: '', search: '' }, crystals: { loaded: false, items: [], search: '', lessonMap: {} }, profile: { loaded: false, projects: [], selectedProject: '', data: null }, @@ -1141,6 +1194,45 @@

agentmemory

if (!ts) return ''; try { return new Date(ts).toLocaleTimeString(); } catch { return ts; } } + // Observation subtitles are often the raw tool input serialized as + // JSON ('{"file_path":"src/x.ts"}'). Pull the human-meaningful field + // out for display; anything unparseable renders as-is. + // Health alerts/notes arrive as compact machine slugs + // (memory_heap_tight_93%_rss111mb). Translate the known families + // into sentences; unknown slugs render as-is. + function humanizeHealthFlag(f) { + var m; + if ((m = /^memory_heap_tight_(\d+)%_rss(\d+)mb$/.exec(f))) + return 'Heap is running tight: ' + m[1] + '% of allocated heap in use (process memory ' + m[2] + ' MB). Informational — Node grows the heap on demand.'; + if ((m = /^memory_(warn|critical)_(\d+)%_rss(\d+)mb$/.exec(f))) + return 'Memory ' + (m[1] === 'critical' ? 'critically high' : 'elevated') + ': ' + m[2] + '% of heap in use, process memory ' + m[3] + ' MB.'; + if ((m = /^cpu_(warn|critical)_(\d+)%$/.exec(f))) + return 'CPU ' + (m[1] === 'critical' ? 'critically high' : 'elevated') + ': ' + m[2] + '%.'; + if ((m = /^event_loop_lag_(warn|critical)_(\d+)ms$/.exec(f))) + return 'Event loop ' + (m[1] === 'critical' ? 'severely delayed' : 'delayed') + ': ' + m[2] + ' ms behind. The worker is busy or blocked.'; + if (f === 'connection_reconnecting') + return 'Engine connection lost — reconnecting.'; + if ((m = /^connection_(.+)$/.exec(f))) + return 'Engine connection state: ' + m[1] + '.'; + return f; + } + + function humanizeSubtitle(s) { + if (typeof s !== 'string') return ''; + var t = s.trim(); + if (!t.startsWith('{')) return s; + try { + var o = JSON.parse(t); + if (o && typeof o === 'object') { + var keys = ['file_path', 'filepath', 'filePath', 'path', 'file', 'command', 'pattern', 'url', 'query', 'prompt']; + for (var i = 0; i < keys.length; i++) { + if (typeof o[keys[i]] === 'string' && o[keys[i]].length > 0) return o[keys[i]]; + } + } + } catch (_) {} + return s; + } + function truncate(s, n) { if (!s) return ''; return s.length > n ? s.slice(0, n) + '...' : s; @@ -1150,7 +1242,15 @@

agentmemory

} function shortSessionId(s, n) { var id = sessionId(s); - return id ? id.slice(0, n || 8) : ''; + if (!id) return ''; + var max = n || 8; + if (id.length <= max) return id; + // Session ids share a long common prefix (demo_msuaboq7_...); the + // distinguishing part is the tail. Keep head + tail so truncated + // ids stay tellable apart in lists and dropdowns. + var tail = 6; + var head = Math.max(2, max - tail - 1); + return id.slice(0, head) + '…' + id.slice(-tail); } function sessionDisplayName(s) { var project = s && s.project ? String(s.project).split('/').pop() : ''; @@ -1335,26 +1435,41 @@

agentmemory

loadTab(tab); } + // Per-tab freshness stamps. Tabs refetch on entry (the loaded-once + // model went stale the moment anything wrote through the API), but a + // short window stops rapid tab flipping from re-issuing the full + // fan-out (dashboard alone is ~10 requests) on every click. + var tabFetchedAt = {}; + var TAB_FRESH_MS = 5000; + async function loadTab(tab) { + var now = Date.now(); + if (tab !== 'replay' && tabFetchedAt[tab] && now - tabFetchedAt[tab] < TAB_FRESH_MS) { + return; + } + tabFetchedAt[tab] = now; switch(tab) { - case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break; - case 'graph': if (!state.graph.loaded) await loadGraph(); break; - case 'memories': if (!state.memories.loaded) await loadMemories(); break; - case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break; - case 'sessions': if (!state.sessions.loaded) await loadSessions(); break; - case 'lessons': if (!state.lessons.loaded) await loadLessons(); break; - case 'actions': if (!state.actions.loaded) await loadActions(); break; - case 'crystals': if (!state.crystals.loaded) await loadCrystals(); break; - case 'audit': if (!state.audit.loaded) await loadAudit(); break; - case 'activity': if (!state.activity.loaded) await loadActivity(); break; - case 'profile': if (!state.profile.loaded) await loadProfile(); break; + case 'dashboard': await loadDashboard(); break; + case 'graph': await loadGraph(); break; + case 'memories': await loadMemories(); break; + case 'timeline': await loadTimeline(); break; + case 'sessions': await loadSessions(); break; + case 'lessons': await loadLessons(); break; + case 'actions': await loadActions(); break; + case 'crystals': await loadCrystals(); break; + case 'audit': await loadAudit(); break; + case 'activity': await loadActivity(); break; + case 'profile': await loadProfile(); break; + // Replay stays fetch-once: reloading it would reset playback + // timer and cursor state mid-session; its toolbar has an explicit + // Refresh button instead. case 'replay': if (!state.replay.loaded) await loadReplay(); break; } } async function loadDashboard() { var el = document.getElementById('view-dashboard'); - el.innerHTML = '
Loading dashboard...
'; + if (!state.dashboard.loaded) el.innerHTML = '
Loading dashboard...
'; try { var results = await Promise.all([ api('health', { readErrorBody: true }), @@ -1428,13 +1543,13 @@

agentmemory

'
'; } 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 += '
'; + 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 += ''; }); - 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 += '
'; - 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 += '
🗒

No sessions

'; } 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 += ''; + html += '
LessonConfidenceReinforcementsSourceProjectUpdated
'; 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 += ''; html += ''; html += ''; @@ -3125,6 +3375,21 @@

agentmemory

html += ''; html += ''; html += ''; + if (expanded) { + html += ''; + } }); html += '
LessonConfidenceUsesSourceProjectUpdated
' + esc(truncate(l.content, 120)) + (l.context ? '
' + esc(truncate(l.context, 80)) + '
' : '') + '
' + confPct + '%
' + (l.reinforcements || 0) + '' + esc(l.project || '-') + '' + shortTime(l.updatedAt) + '
'; + 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 += '
'; } @@ -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 += '