commit 979fb22d7c8b6ecc8f613bcf8dcf1f67d20092f4 Author: wehub-resource-sync Date: Mon Jul 13 13:01:18 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..4db7b4a --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "agentmemory", + "owner": { + "name": "Rohit Ghumare", + "github": "rohitg00" + }, + "plugins": [ + { + "name": "agentmemory", + "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions", + "source": "./plugin" + } + ] +} diff --git a/.codex-plugin/marketplace.json b/.codex-plugin/marketplace.json new file mode 100644 index 0000000..65ec86c --- /dev/null +++ b/.codex-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "agentmemory", + "interface": { + "displayName": "agentmemory" + }, + "plugins": [ + { + "name": "agentmemory", + "source": { + "source": "git-subdir", + "url": "https://github.com/rohitg00/agentmemory.git", + "path": "./plugin", + "ref": "main" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Memory" + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..77ca0f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,175 @@ +# ============================================================================= +# agentmemory configuration +# ============================================================================= +# +# Copy this file to `~/.agentmemory/.env` (or to your project root if you +# prefer scoped config) and uncomment the lines you want to override. +# +# Every line is OFF by default — `agentmemory` runs out of the box with no +# LLM key, no embedding key, and no API auth. Set keys here only when you +# want to enable the corresponding feature. +# +# Run `npx @agentmemory/agentmemory init` to copy this file into place +# automatically. Run `npx @agentmemory/agentmemory doctor` to verify that +# the daemon reads the env you expect. +# +# Defaults shown in comments. Listed in priority order — the first key +# present wins on the LLM detection path (see src/config.ts::detectProvider). + +# ----------------------------------------------------------------------------- +# 1. LLM provider — pick ONE +# ----------------------------------------------------------------------------- +# +# Without a provider key, agentmemory runs in noop mode: observations are +# indexed via zero-LLM synthetic compression, hybrid search still works, +# but LLM-backed summarisation / reflection / consolidation are disabled. +# 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_BASE_URL=https://api.openai.com # Override for OpenAI-compatible providers + +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_MODEL=claude-sonnet-4-20250514 # 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) + +# OPENROUTER_API_KEY=sk-or-... +# OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514 + +# MINIMAX_API_KEY=... +# MINIMAX_MODEL=MiniMax-M2.7 + +# MAX_TOKENS=4096 # Cap LLM completion tokens for compression / summarise calls + +# Outbound LLM / embedding timeout — shared across every raw-fetch provider +# (Gemini, OpenRouter, MiniMax, OpenAI LLM, and OpenAI/Cohere/Voyage/OpenRouter +# embedding). The OpenAI LLM path also honours the OpenAI-scoped +# OPENAI_TIMEOUT_MS alias for back-compat with v0.9.17 (precedence). +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s) + +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk +# child sessions). Off by default — the agent-sdk fallback can trigger +# Stop-hook recursion (#149 follow-up) when invoked from inside Claude Code. +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# FALLBACK_PROVIDERS=anthropic,gemini # Comma-separated chain tried after the primary provider returns an error (e.g. rate limit) + +# ----------------------------------------------------------------------------- +# 2. Embedding provider — auto-detected, override via EMBEDDING_PROVIDER +# ----------------------------------------------------------------------------- +# +# Without an embedding key, agentmemory runs in BM25-only mode for hybrid +# search. Detection order: EMBEDDING_PROVIDER override → GEMINI_API_KEY → +# OPENAI_API_KEY → VOYAGE_API_KEY → COHERE_API_KEY → OPENROUTER_API_KEY → +# local (Xenova/all-MiniLM-L6-v2, 384-dim). + +# EMBEDDING_PROVIDER=local # local | openai | voyage | cohere | gemini | openrouter + +# VOYAGE_API_KEY=pa-... # Optimised for code embeddings + +# COHERE_API_KEY=... # General-purpose embeddings + +# Reuses OPENAI_API_KEY / OPENAI_BASE_URL above when EMBEDDING_PROVIDER=openai. +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small # Embedding model when EMBEDDING_PROVIDER=openai +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small # When EMBEDDING_PROVIDER=openrouter + +# ----------------------------------------------------------------------------- +# 3. Auth & security +# ----------------------------------------------------------------------------- +# +# Bearer-token auth for the REST API + viewer + all integration plugins. +# Without a secret, REST endpoints are open on loopback. Set this when +# you expose the daemon beyond loopback or run behind a reverse proxy. + +# AGENTMEMORY_SECRET=your-secret-here + +# ----------------------------------------------------------------------------- +# 4. Search tuning +# ----------------------------------------------------------------------------- + +# BM25_WEIGHT=0.4 # Hybrid search weight for BM25 leg +# VECTOR_WEIGHT=0.6 # Hybrid search weight for vector leg +# AGENTMEMORY_GRAPH_WEIGHT=0.2 # Graph traversal bonus on smart-search ranking +# TOKEN_BUDGET=2000 # Max tokens injected via mem::context per session +# MAX_OBS_PER_SESSION=500 # Per-session observation cap before consolidation kicks in +# SUMMARIZE_CHUNK_SIZE=400 # When mem::summarize sees a session larger than this, it chunks observations and map-reduces (chunk-summarize → reduce-merge) to stay within the LLM's context window. Default 400 ≈ 50k tokens per chunk at ~110 tok/obs. Native sessions are capped by MAX_OBS_PER_SESSION; chunking primarily matters for bulk-imported jsonl sessions, which bypass that cap. +# SUMMARIZE_CHUNK_CONCURRENCY=6 # Parallel chunk LLM calls during chunked summarize. Default 6 fits ~100-chunk sessions under iii's 180s function-invocation timeout at typical ~8s/call. High-throughput providers (Novita, DeepInfra, DeepSeek) commonly allow 100+ concurrent — bump this for very large imported sessions. + +# ----------------------------------------------------------------------------- +# 5. Behaviour flags +# ----------------------------------------------------------------------------- + +# AGENTMEMORY_AUTO_COMPRESS=true # Run LLM compression on every observation batch (requires a provider key). Default off — synthetic compression handles most cases. +# AGENTMEMORY_INJECT_CONTEXT=true # Inject recalled memories back into agent prompts (#143). Default off — hooks capture observations but do not modify conversation. +# CONSOLIDATION_ENABLED=true # Run the 4-tier consolidation pipeline (memories → semantic → procedural). Default off — opt in once you've measured the LLM cost. +# 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 +# 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). + +# ----------------------------------------------------------------------------- +# 6. CLI / runtime knobs +# ----------------------------------------------------------------------------- + +# AGENTMEMORY_TOOLS=all # core (7 tools, default) | all (51 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) +# AGENTMEMORY_PROBE_TIMEOUT_MS=2000 # MCP shim livez probe timeout +# AGENTMEMORY_URL=http://localhost:3111 # REST base URL — honored by status, doctor, MCP shim +# AGENTMEMORY_VIEWER_URL=http://localhost:3113 # Override the viewer URL printed by `agentmemory status` +# AGENTMEMORY_EXPORT_ROOT=~/agentmemory-backup # Default destination for `agentmemory export` + +# STANDALONE_MCP=1 # MCP shim only — bypass the worker and run @agentmemory/mcp in-process +# STANDALONE_PERSIST_PATH=~/.agentmemory/local.db # Path used by the standalone MCP shim's local fallback store + +# Snapshot exporter — periodic snapshots of state_store + stream_store. +# SNAPSHOT_ENABLED=true +# SNAPSHOT_DIR=~/.agentmemory/snapshots +# SNAPSHOT_INTERVAL=3600 # Seconds between snapshots + +# Team sharing — when set, memories are scoped to (TEAM_ID, USER_ID) tuples. +# TEAM_MODE=shared +# TEAM_ID=acme +# USER_ID=rohit + +# ----------------------------------------------------------------------------- +# 7. Ports +# ----------------------------------------------------------------------------- + +# III_REST_PORT=3111 # REST API port (also affects viewer at +2) +# III_STREAMS_PORT=3112 # Streams API port +# III_ENGINE_URL=ws://localhost:49134 # iii-engine WebSocket URL (used by the worker) + +# ----------------------------------------------------------------------------- +# 8. iii engine pin +# ----------------------------------------------------------------------------- +# +# 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. Override with AGENTMEMORY_III_VERSION +# only after migrating to the sandbox model manually. + +# AGENTMEMORY_III_VERSION=0.11.2 + +# ----------------------------------------------------------------------------- +# 9. Claude Code bridge (opt-in) +# ----------------------------------------------------------------------------- + +# CLAUDE_MEMORY_BRIDGE=true # Mirror compressed memories into Claude Code's CLAUDE.md +# CLAUDE_PROJECT_PATH=/path/to/your/project # Required when CLAUDE_MEMORY_BRIDGE=true +# CLAUDE_MEMORY_LINE_BUDGET=200 # Lines of memory CLAUDE.md should hold + +# ----------------------------------------------------------------------------- +# 10. Obsidian export (opt-in) +# ----------------------------------------------------------------------------- + +# OBSIDIAN_AUTO_EXPORT=true # Auto-export memories to an Obsidian vault on every consolidation diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a2f5e0c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [rohitg00] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e0a74e9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,110 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 10 + labels: + - dependencies + - npm + commit-message: + prefix: deps + include: scope + groups: + minor-and-patch: + update-types: + - minor + - patch + + - package-ecosystem: npm + directory: /website + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 10 + labels: + - dependencies + - npm + - website + commit-message: + prefix: deps(website) + include: scope + groups: + minor-and-patch: + update-types: + - minor + - patch + + - package-ecosystem: npm + directory: /integrations/openclaw + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + labels: + - dependencies + - npm + - integrations + commit-message: + prefix: deps(openclaw) + include: scope + + - package-ecosystem: npm + directory: /integrations/pi + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + labels: + - dependencies + - npm + - integrations + commit-message: + prefix: deps(pi) + include: scope + + - package-ecosystem: npm + directory: /integrations/filesystem-watcher + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + labels: + - dependencies + - npm + - integrations + commit-message: + prefix: deps(fs-watcher) + include: scope + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Etc/UTC + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions + commit-message: + prefix: ci(deps) + include: scope + groups: + minor-and-patch: + update-types: + - minor + - patch diff --git a/.github/security-advisories/01-viewer-xss.md b/.github/security-advisories/01-viewer-xss.md new file mode 100644 index 0000000..046c286 --- /dev/null +++ b/.github/security-advisories/01-viewer-xss.md @@ -0,0 +1,46 @@ +# GHSA Draft: Stored XSS in agentmemory real-time viewer + +**Severity:** Critical · **CVSS 3.1:** 9.6 (`AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:L`) +**CWE:** [CWE-79 — Improper Neutralization of Input During Web Page Generation](https://cwe.mitre.org/data/definitions/79.html) +**Affected versions:** `< 0.8.2` +**Patched version:** `0.8.2` + +## Summary + +agentmemory's real-time viewer (default port 3113) rendered user-controlled data — tool outputs, file paths, memory titles, observation content — into HTML using inline `onclick=` event handlers. The viewer's Content Security Policy simultaneously allowed `script-src 'unsafe-inline'`, meaning injected JavaScript would execute in the reader's browser context. + +## Impact + +Any data captured by agentmemory hooks — which includes tool output from Claude Code, Cursor, or any other agent — becomes an XSS vector when the user opens the viewer. An attacker with the ability to influence any captured observation (e.g., by sending a crafted file contents to be read by an agent, or by planting a malicious commit message in a repository) could: + +- Exfiltrate the entire memory store via authenticated requests from the browser +- Read `AGENTMEMORY_SECRET` if the viewer was configured with auth +- Make requests to arbitrary endpoints on behalf of the viewer user +- Modify the DOM to mislead the developer +- Pivot to other localhost services on the developer's machine + +The viewer runs on localhost by default but is **reachable from the browser**, so standard same-origin protections don't help. + +## Patches + +Fixed in **0.8.2**: + +- All inline `on*=` handlers removed from `src/viewer/index.html` +- Replaced with delegated `data-action` event handling +- CSP switched to a **per-response script nonce** (`script-src 'nonce-'`) +- Added `script-src-attr 'none'` to block any inline handler attributes even if injected +- Viewer HTML now rendered through `src/viewer/document.ts` which generates a fresh nonce per request + +## Workarounds + +**None.** Users on affected versions should upgrade to 0.8.2 immediately. Do not open `http://localhost:3113` in a browser on affected versions if you suspect any of your captured observations may contain attacker-controlled content. + +## References + +- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108) +- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f) +- Reporter: @eng-pf + +## Credit + +@eng-pf submitted PR #108 with fixes for this and 5 other vulnerabilities. diff --git a/.github/security-advisories/02-curl-sh-rce.md b/.github/security-advisories/02-curl-sh-rce.md new file mode 100644 index 0000000..f32a3e8 --- /dev/null +++ b/.github/security-advisories/02-curl-sh-rce.md @@ -0,0 +1,57 @@ +# GHSA Draft: Remote shell script execution in agentmemory CLI startup + +**Severity:** Critical · **CVSS 3.1:** 9.8 (`AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H`) +**CWE:** [CWE-494 — Download of Code Without Integrity Check](https://cwe.mitre.org/data/definitions/494.html), [CWE-829 — Inclusion of Functionality from Untrusted Control Sphere](https://cwe.mitre.org/data/definitions/829.html) +**Affected versions:** `< 0.8.2` +**Patched version:** `0.8.2` + +## Summary + +The agentmemory CLI (`npx @agentmemory/agentmemory`) auto-installed the iii-engine binary by piping a remote shell script into `sh`: + +```ts +execSync("curl -fsSL https://install.iii.dev/iii/main/install.sh | sh") +``` + +This happened automatically on first run if `iii` was not found in `$PATH`. The script was fetched over HTTPS and executed with the permissions of the user running `npx agentmemory`. No checksum verification, no pinned version, no signature check. + +## Impact + +If `install.iii.dev` were ever compromised — via DNS hijack, domain takeover, expired certificate + MITM on an untrusted network, BGP attack, or any other supply chain attack — **every new agentmemory user would execute attacker-controlled shell code** as their own user. + +This is the canonical "curl | sh" supply chain anti-pattern. It affected: +- Developers running `npx @agentmemory/agentmemory` for the first time +- CI/CD pipelines that installed agentmemory fresh +- Docker builds that installed agentmemory as part of an image + +## Patches + +Fixed in **0.8.2**: + +- Removed `execSync` call entirely from `src/cli.ts` +- CLI now uses an existing local `iii` binary if present in `$PATH` +- Falls back to Docker Compose (`docker compose up -d`) if Docker is available +- Shows manual install instructions if neither iii nor Docker is found: + - `cargo install iii-engine` + - `docker pull iiidev/iii:latest` + - Docs link: https://iii.dev/docs + +## Workarounds + +Users on affected versions should **install iii-engine manually** and run `agentmemory --no-engine` until upgraded: + +```bash +cargo install iii-engine +npx @agentmemory/agentmemory@0.8.1 --no-engine +``` + +Then upgrade to 0.8.2 at the earliest opportunity. + +## References + +- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108) +- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f) + +## Credit + +@eng-pf diff --git a/.github/security-advisories/03-default-bind-0000.md b/.github/security-advisories/03-default-bind-0000.md new file mode 100644 index 0000000..f244e5d --- /dev/null +++ b/.github/security-advisories/03-default-bind-0000.md @@ -0,0 +1,62 @@ +# GHSA Draft: agentmemory REST and stream services bound to 0.0.0.0 by default + +**Severity:** High · **CVSS 3.1:** 8.1 (`AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L`) +**CWE:** [CWE-668 — Exposure of Resource to Wrong Sphere](https://cwe.mitre.org/data/definitions/668.html), [CWE-306 — Missing Authentication for Critical Function](https://cwe.mitre.org/data/definitions/306.html) +**Affected versions:** `< 0.8.2` +**Patched version:** `0.8.2` + +## Summary + +The default `iii-config.yaml` bound both the REST API (port 3111) and the streams server (port 3112) to `0.0.0.0`, exposing them on every network interface the host could reach. Combined with the fact that `AGENTMEMORY_SECRET` is **unset by default**, this meant any device on the same local network as a running agentmemory instance could read the entire memory store without authentication. + +Affected endpoints included: +- `GET /agentmemory/export` — full dump of every captured observation, memory, session, and audit entry +- `GET /agentmemory/sessions` — session list +- `POST /agentmemory/smart-search` — arbitrary search over all captured content +- `POST /agentmemory/observe` — ability to **inject** fake observations +- `POST /agentmemory/remember` — ability to plant arbitrary memories +- All 109 other REST endpoints + +## Impact + +A developer running agentmemory on a laptop in a coffee shop, office, or conference WiFi effectively published their entire memory store — including captured API keys, file contents, prompts, decisions, and project context — to anyone on the same network. + +Attackers on the same network could: + +1. **Exfiltrate secrets.** `curl http://:3111/agentmemory/export` downloads everything. Depending on the incompleteness of the secret redaction (see advisory #06), this could include API keys and tokens. +2. **Inject memories.** An attacker could `POST /agentmemory/observe` or `/remember` with fake observations, poisoning the memory store so future sessions retrieve attacker-controlled context. +3. **Pivot to other services.** The mesh sync endpoint (before the auth fix in advisory #04) accepted peer data from any source. + +## Patches + +Fixed in **0.8.2**: + +- `iii-config.yaml` now binds REST, streams to `127.0.0.1` +- Viewer server already bound to `127.0.0.1` +- New `iii-config.docker.yaml` for Docker deployments: containers bind to `0.0.0.0` internally (required for Docker networking) but host port mapping is restricted to `127.0.0.1:port` in `docker-compose.yml` +- README and API section documentation updated to note 127.0.0.1 as the default + +## Workarounds + +Users on affected versions should manually edit their `iii-config.yaml` and change the REST and streams `host` values to `127.0.0.1`: + +```yaml +modules: + - class: modules::api::RestApiModule + config: + host: 127.0.0.1 # was 0.0.0.0 + - class: modules::stream::StreamModule + config: + host: 127.0.0.1 # was 0.0.0.0 +``` + +And set `AGENTMEMORY_SECRET` to a strong random value to protect endpoints even if network exposure is needed. + +## References + +- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108) +- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f) + +## Credit + +@eng-pf diff --git a/.github/security-advisories/04-mesh-unauth.md b/.github/security-advisories/04-mesh-unauth.md new file mode 100644 index 0000000..d7ffe2e --- /dev/null +++ b/.github/security-advisories/04-mesh-unauth.md @@ -0,0 +1,47 @@ +# GHSA Draft: Unauthenticated mesh sync in agentmemory + +**Severity:** High · **CVSS 3.1:** 7.4 (`AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N`) +**CWE:** [CWE-306 — Missing Authentication for Critical Function](https://cwe.mitre.org/data/definitions/306.html), [CWE-862 — Missing Authorization](https://cwe.mitre.org/data/definitions/862.html) +**Affected versions:** `< 0.8.2` +**Patched version:** `0.8.2` + +## Summary + +agentmemory's mesh federation feature (P2P sync between instances) accepted push/pull requests on its `/agentmemory/mesh/*` endpoints without requiring authentication. The mesh sync function also did not send any `Authorization` header when calling peer instances, meaning the federation protocol was entirely unauthenticated. + +## Impact + +Any attacker who could reach a mesh-enabled agentmemory instance could: + +1. **Push fake memories** via `POST /agentmemory/mesh/receive` — inject attacker-controlled observations, actions, semantic memories, and relations into the target's memory store. This poisons future retrievals and could be used to manipulate what the target's AI agent sees. +2. **Pull the entire memory store** via `GET /agentmemory/mesh/export` — download all memories, actions, and graph data marked as mesh-shareable. +3. **Chain with advisory #03** — combined with the default `0.0.0.0` binding, mesh endpoints were reachable from any device on the local network without any authentication. + +Mesh is opt-in (requires an explicit peer registration), so this affected only users who had enabled federation. But those users had no authentication at all. + +## Patches + +Fixed in **0.8.2**: + +- All 5 mesh REST endpoints (`mesh-register`, `mesh-list`, `mesh-sync`, `mesh-receive`, `mesh-export`) now return 503 with `"mesh requires AGENTMEMORY_SECRET"` if the secret is not configured +- The `mem::mesh-sync` function now accepts a `meshAuthToken` parameter and **refuses to sync at all** if the token is missing +- Outgoing push/pull requests include `Authorization: Bearer ` headers +- Server-side, all mesh endpoints check bearer auth via the existing `checkAuth` helper + +## Workarounds + +Users on affected versions who have mesh federation enabled should: +1. Set `AGENTMEMORY_SECRET` to a strong random value on **both** peers +2. Restart the server +3. Upgrade to 0.8.2 at the earliest opportunity + +Users who have **not** enabled mesh federation are not affected by this specific issue, but should still upgrade for the other 5 fixes. + +## References + +- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108) +- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f) + +## Credit + +@eng-pf diff --git a/.github/security-advisories/05-obsidian-export-traversal.md b/.github/security-advisories/05-obsidian-export-traversal.md new file mode 100644 index 0000000..13c4b8e --- /dev/null +++ b/.github/security-advisories/05-obsidian-export-traversal.md @@ -0,0 +1,61 @@ +# GHSA Draft: Arbitrary filesystem write via Obsidian export in agentmemory + +**Severity:** Medium · **CVSS 3.1:** 6.5 (`AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L`) +**CWE:** [CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')](https://cwe.mitre.org/data/definitions/22.html), [CWE-73 — External Control of File Name or Path](https://cwe.mitre.org/data/definitions/73.html) +**Affected versions:** `< 0.8.2` +**Patched version:** `0.8.2` + +## Summary + +The `POST /agentmemory/obsidian/export` endpoint accepted a `vaultDir` parameter and passed it directly to `mkdir` and `writeFile` calls without any containment check. A caller could set `vaultDir` to any absolute path on the filesystem and agentmemory would create directories and write Markdown files there with the permissions of the process running the server. + +```bash +# Example exploit payload (affected versions only) +curl -X POST http://localhost:3111/agentmemory/obsidian/export \ + -H "Content-Type: application/json" \ + -d '{"vaultDir": "/etc/cron.d"}' +``` + +The content written would be agentmemory's exported memories in Markdown format, but an attacker could craft specific memory content beforehand to plant arbitrary files. + +## Impact + +When chained with advisory #03 (default `0.0.0.0` binding) or advisory #04 (unauthenticated mesh), an attacker on the local network could write arbitrary files to any filesystem location the agentmemory process had write access to. + +Possible exploitation paths: +- Write to `~/.ssh/authorized_keys` — SSH key injection +- Write to `/etc/cron.d/*` — cron job injection (if running as root) +- Write to `~/.bashrc` or shell rc files — code execution on next shell +- Overwrite any file the process could write to + +## Patches + +Fixed in **0.8.2**: + +- New `AGENTMEMORY_EXPORT_ROOT` environment variable (default: `~/.agentmemory`) +- `vaultDir` now goes through `resolveVaultDir()` in `src/functions/obsidian-export.ts`: + - Resolves the path with `path.resolve` + - Checks `resolved === root || resolved.startsWith(root + path.sep)` + - Returns `null` if the check fails, and the endpoint returns `{ success: false, error: "vaultDir must be inside AGENTMEMORY_EXPORT_ROOT" }` +- Default export is confined to `~/.agentmemory/vault` +- Tests added in `test/obsidian-export.test.ts` for both the custom-but-valid case and the rejection case + +## Known limitations + +`resolveVaultDir()` performs lexical containment only — it does not call `fs.realpathSync` / `fs.lstatSync`. A pre-existing symlink under `AGENTMEMORY_EXPORT_ROOT` that points outside the root can still be written through. Users who allow untrusted processes to create files inside `AGENTMEMORY_EXPORT_ROOT` should additionally run agentmemory inside a sandbox that forbids symlink creation, or file a follow-up issue requesting symlink-aware containment. + +## Workarounds + +Users on affected versions should: +1. **Disable the Obsidian export endpoint** by setting `OBSIDIAN_AUTO_EXPORT=false` (and avoid calling `/agentmemory/obsidian/export` manually) +2. Set `AGENTMEMORY_SECRET` so the endpoint requires bearer auth +3. Upgrade to 0.8.2 + +## References + +- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108) +- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f) + +## Credit + +@eng-pf diff --git a/.github/security-advisories/06-privacy-redaction-incomplete.md b/.github/security-advisories/06-privacy-redaction-incomplete.md new file mode 100644 index 0000000..50c35d4 --- /dev/null +++ b/.github/security-advisories/06-privacy-redaction-incomplete.md @@ -0,0 +1,60 @@ +# GHSA Draft: Incomplete secret redaction in agentmemory privacy filter + +**Severity:** Medium · **CVSS 3.1:** 6.2 (`AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N`) +**CWE:** [CWE-532 — Insertion of Sensitive Information into Log File](https://cwe.mitre.org/data/definitions/532.html), [CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor](https://cwe.mitre.org/data/definitions/200.html) +**Affected versions:** `< 0.8.2` +**Patched version:** `0.8.2` + +## Summary + +agentmemory's privacy filter (`src/functions/privacy.ts`) is supposed to strip API keys, secrets, and bearer tokens from captured observations before they are stored. The filter used regex patterns to detect common token formats. Three modern token formats were missing from the patterns: + +1. **Bearer tokens** — `Authorization: Bearer ` headers were not matched, so any captured HTTP request or response that included an Authorization header flowed into the memory store verbatim. +2. **OpenAI project keys** — `sk-proj-*` (the dominant OpenAI API key format since mid-2024) was not matched. The existing `sk-[A-Za-z0-9]{20,}` pattern only caught the legacy format. +3. **GitHub fine-grained service/user tokens** — `ghs_*` and `ghu_*` were not matched. The existing `ghp_[A-Za-z0-9]{36}` pattern only caught personal access tokens. + +## Impact + +agentmemory's README explicitly claimed "Privacy first — API keys, secrets, and `` tags are stripped before anything is stored." That claim was **false** for three common token formats. + +Users relying on the privacy filter to protect their captured observations had a false sense of security. Tokens matching these three patterns would: + +1. Be captured by `PostToolUse` hooks alongside the rest of the tool output +2. Pass through `stripPrivateData()` unmodified +3. Be LLM-compressed and stored in the memory KV +4. Be exposed to any attacker who could reach the `/agentmemory/export` or `/agentmemory/smart-search` endpoints +5. Be included in Obsidian exports, mesh syncs, and CLAUDE.md bridge writes + +When chained with advisory #03 (default `0.0.0.0` binding), this meant network-adjacent attackers could retrieve captured Bearer tokens, OpenAI keys, and GitHub service tokens from the memory store. + +## Patches + +Fixed in **0.8.2**: + +New regex patterns added to `SECRET_PATTERN_SOURCES` in `src/functions/privacy.ts`: + +```ts +/Bearer\s+[A-Za-z0-9._\-+/=]{20,}/gi, +/sk-proj-[A-Za-z0-9\-_]{20,}/g, +/(?:sk|pk|rk|ak)-[A-Za-z0-9][A-Za-z0-9\-_]{19,}/g, +/gh[pus]_[A-Za-z0-9]{36,}/g, +``` + +Three new unit tests in `test/privacy.test.ts` verify each format is now stripped. + +## Workarounds + +Users on affected versions should: +1. Avoid having agents read files or API responses containing these token formats +2. Use the `` tag around any block containing secrets — that filter was not affected +3. Set `AGENTMEMORY_SECRET` to restrict API access +4. Upgrade to 0.8.2 + +## References + +- Fix PR: [#108](https://github.com/rohitg00/agentmemory/pull/108) +- Commit: [`cbaaf4f`](https://github.com/rohitg00/agentmemory/commit/cbaaf4f) + +## Credit + +@eng-pf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed53c6d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +# `paths-ignore` keeps doc-only / website / README / CHANGELOG churn from +# burning runner minutes. Source / config / workflow changes always run. +# `workflow_dispatch` gives a manual re-run button for flake debugging. +on: + push: + branches: [main] + paths-ignore: + - "README.md" + - "CHANGELOG.md" + - "AGENTS.md" + - "ROADMAP.md" + - "website/**" + - "docs/**" + - "assets/**" + - "deploy/**/README.md" + - "**/*.md" + - "**/*.mdx" + pull_request: + branches: [main] + paths-ignore: + - "README.md" + - "CHANGELOG.md" + - "AGENTS.md" + - "ROADMAP.md" + - "website/**" + - "docs/**" + - "assets/**" + - "deploy/**/README.md" + - "**/*.md" + - "**/*.mdx" + workflow_dispatch: + +# Cancel in-flight PR runs when a force-push lands. Keep push runs to +# protect against partial state on main. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + # Don't bail the whole matrix on one cell's failure — we want to + # see whether the same failure reproduces across OSes (e.g. + # whether a flake is platform-specific or universal). + fail-fast: false + matrix: + # Windows held back: test/obsidian-export.test.ts has hardcoded + # POSIX paths (`/tmp/...`) that fail on D:\ drive runners. + # src/functions/obsidian-export.ts needs os.tmpdir() + path.join + # rework before Windows can be added back. Tracked as follow-up. + os: [ubuntu-latest, macos-latest] + node-version: [20, 22] + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + # Two-step install: generate a lockfile in-runner with + # --package-lock-only, then install from it with `npm ci`. + # Lockfiles are gitignored at the repo level. + - run: npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund + - run: npm ci --legacy-peer-deps --no-audit --no-fund + - run: npm run build + - run: npm run skills:check + - run: npm test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0000339 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,124 @@ +name: Publish to npm + +on: + release: + types: [published] + workflow_dispatch: + inputs: + packages: + description: "Packages to publish (comma-separated: agentmemory,mcp,fs-watcher)" + required: false + default: "agentmemory,mcp,fs-watcher" + +# Workflow-level permissions stay minimal — only `contents: read` +# is required to check out the repo. `id-token: write` is granted on +# the publish job for npm's --provenance Sigstore OIDC mint. +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v6 + with: + # Don't persist the GITHUB_TOKEN to .git/config — the + # publish steps don't push back to the repo, so the token + # only needs to live in memory for this checkout. + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + # Two-step install: generate a lockfile in-runner with + # --package-lock-only, then install from it with `npm ci`. Gives a + # single deterministic dep graph across build / test / publish + # within one job — important because publish uses `--provenance`. + # Lockfiles are gitignored at the repo level. + - run: npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund + - run: npm ci --legacy-peer-deps --no-audit --no-fund + - run: npm run build + - run: npm test + + - name: Publish @agentmemory/agentmemory + run: | + if npm view "@agentmemory/agentmemory@$(node -p "require('./package.json').version")" version >/dev/null 2>&1; then + echo "Version already published, skipping" + else + npm publish --provenance --access public + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Wait for npm registry propagation + run: | + VERSION=$(node -p "require('./package.json').version") + for i in $(seq 1 24); do + if npm view "@agentmemory/agentmemory@$VERSION" version >/dev/null 2>&1; then + echo "Registry propagated after ${i} attempt(s)" + exit 0 + fi + echo "Attempt $i: not yet available, sleeping 5s..." + sleep 5 + done + echo "ERROR: registry never propagated after 2 minutes" >&2 + exit 1 + + - name: Publish @agentmemory/mcp shim + working-directory: packages/mcp + run: | + SHIM_VERSION=$(node -p "require('./package.json').version") + if npm view "@agentmemory/mcp@$SHIM_VERSION" version >/dev/null 2>&1; then + echo "Shim version already published, skipping" + else + npm publish --provenance --access public + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Wait for @agentmemory/mcp registry propagation + working-directory: packages/mcp + run: | + SHIM_VERSION=$(node -p "require('./package.json').version") + for i in $(seq 1 24); do + if npm view "@agentmemory/mcp@$SHIM_VERSION" version >/dev/null 2>&1; then + echo "Shim propagated after ${i} attempt(s)" + exit 0 + fi + echo "Attempt $i: not yet available, sleeping 5s..." + sleep 5 + done + echo "ERROR: shim never propagated after 2 minutes" >&2 + exit 1 + + - name: Publish @agentmemory/fs-watcher connector + working-directory: integrations/filesystem-watcher + run: | + FSW_VERSION=$(node -p "require('./package.json').version") + if npm view "@agentmemory/fs-watcher@$FSW_VERSION" version >/dev/null 2>&1; then + echo "fs-watcher version already published, skipping" + else + npm publish --provenance --access public + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Wait for @agentmemory/fs-watcher registry propagation + working-directory: integrations/filesystem-watcher + run: | + FSW_VERSION=$(node -p "require('./package.json').version") + for i in $(seq 1 24); do + if npm view "@agentmemory/fs-watcher@$FSW_VERSION" version >/dev/null 2>&1; then + echo "fs-watcher propagated after ${i} attempt(s)" + exit 0 + fi + echo "Attempt $i: not yet available, sleeping 5s..." + sleep 5 + done + echo "ERROR: fs-watcher never propagated after 2 minutes" >&2 + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba6af99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +node_modules/ +dist/ +*.tsbuildinfo + +.env +.env.* +!.env.example + +*.log +.DS_Store +.claude/ + +plugin/scripts/*.map +plugin/scripts/*.d.mts +data/ +!eval/data/ +!eval/data/** +data-*/ +agentmemory-debug/ +.gstack/ + +# Lock files — never commit (see feedback_no_lockfiles memory) +package-lock.json +pnpm-lock.yaml +yarn.lock +integrations/hermes/__pycache__/ + +# Eval reports (transient; published scorecards live in docs/benchmarks/) +eval/reports/ +# LongMemEval download is 278MB; fetched on demand +eval/data/longmemeval/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..873ef10 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,124 @@ +# agentmemory — Agent Instructions + +## Architecture + +agentmemory is a persistent memory system for AI coding agents, built on iii-engine's three primitives (Worker/Function/Trigger). Everything goes through `registerFunction`/`registerTrigger`/`sdk.trigger()` — never bypass iii-engine with standalone SQLite or in-process alternatives. + +- **Engine**: iii-sdk (WebSocket to iii-engine on port 49134) +- **State**: File-based SQLite via iii-engine's StateModule (`./data/state_store.db`) +- **Build**: TypeScript → ESM via tsdown, output to `dist/` +- **Test**: vitest (`npm test` excludes integration tests) + +## Consistency Rules + +**When adding or removing MCP tools, you MUST update ALL of the following:** +1. `src/mcp/tools-registry.ts` — tool definition + `getAllTools()` array +2. `src/mcp/server.ts` — handler case in the `mcp::tools::call` switch +3. `src/triggers/api.ts` — REST endpoint registration +4. `src/index.ts` — function registration + endpoint count in the log line +5. `test/mcp-standalone.test.ts` — tool count assertion +6. `README.md` — tool counts (search for "MCP tools") +7. `plugin/.claude-plugin/plugin.json` — tool count in description +8. `plugin/plugin.json` and `plugin/.mcp.copilot.json` (when present) — tool count or MCP exposure + +**When adding REST endpoints, you MUST update:** +1. `src/triggers/api.ts` — endpoint registration +2. `src/index.ts` — endpoint count in the log line +3. `README.md` — endpoint count (search for "REST endpoints" and "endpoints on port") + +**When bumping version, you MUST update ALL of the following:** +1. `package.json` — version field +2. `src/version.ts` — VERSION constant and type union +3. `src/types.ts` — ExportData version union +4. `src/functions/export-import.ts` — supportedVersions set +5. `test/export-import.test.ts` — version assertion +6. `plugin/.claude-plugin/plugin.json` — version field +7. `plugin/plugin.json` (when present) — version field + +**When adding new KV scopes:** +1. `src/state/schema.ts` — add to the KV object +2. `src/types.ts` — add the corresponding interface + +**When adding new audit operations:** +1. `src/types.ts` — add to AuditEntry.operation union type + +## Code Patterns + +### Function Registration +```typescript +sdk.registerFunction( + "mem::your-function", + async (data: { ... }) => { + // validate inputs + // do work via kv.get/kv.set/kv.list + // record audit via recordAudit() + return { success: true, ... }; + }, +); +``` + +### REST Endpoint Registration +```typescript +sdk.registerFunction("api::your-endpoint", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + // validate + whitelist fields (never pass raw body to sdk.trigger) + const result = await sdk.trigger({ + function_id: "mem::your-function", + payload: { ... }, + }); + return { status_code: 200, body: result }; +}); +sdk.registerTrigger({ + type: "http", + function_id: "api::your-endpoint", + config: { api_path: "/agentmemory/your-path", http_method: "POST" }, +}); +``` + +### MCP Tool Handler +```typescript +case "memory_your_tool": { + // validate args with typeof checks + // parse CSV args: args.field.split(",").map(t => t.trim()).filter(Boolean) + const result = await sdk.trigger({ + function_id: "mem::your-function", + payload: { ... }, + }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } }; +} +``` + +### Hook Scripts +Hook scripts in `src/hooks/` are standalone Node.js scripts (no iii-sdk import). They read JSON from stdin, make HTTP calls to the REST API, and exit. There are two patterns depending on whether Claude Code consumes the script's stdout: + +- **Context-injecting hooks** (`pre-tool-use`, `pre-compact`, `session-start`) write recalled context to stdout for Claude Code to inject. These MUST use `try/catch` with `await fetch(..., { signal: AbortSignal.timeout(N) })` — the script has to wait for the response before exiting, and the timeout is the only bound on hang time. +- **Telemetry-only hooks** (`notification`, `post-tool-failure`, `post-tool-use`, `prompt-submit`, `stop`, `session-end`, `subagent-start`, `subagent-stop`, `task-completed`) write nothing to stdout. These MUST use fire-and-forget `fetch(..., { signal: AbortSignal.timeout(N) }).catch(() => {})` paired with `setTimeout(() => process.exit(0), 500).unref()`. The unawaited fetch dispatches the request; the unref'd `setTimeout` force-exits the process after the request has been flushed to the local daemon's socket buffer (~500ms is enough for single-request hooks; use 1500ms for multi-request hooks like `stop` and `session-end` so all fetches have time to start, especially when `AGENTMEMORY_URL` points to a remote daemon). Without the `setTimeout` Node keeps the event loop alive waiting for any in-flight fetch to settle, which means the hook still blocks Claude Code's next-prompt boundary for up to the AbortSignal duration — exactly the bug fire-and-forget is meant to fix. + +## Coding Standards + +- TypeScript, ESM only (`"type": "module"`) +- No code comments explaining WHAT — use clear naming instead +- Use `fingerprintId()` for content-addressable dedup, `generateId()` for unique IDs +- Parallel operations where possible (`Promise.all` for independent kv writes/reads) +- Input validation at system boundaries (MCP handlers, REST endpoints) +- REST endpoints must whitelist fields — never pass raw request body to `sdk.trigger()` +- Use `recordAudit()` for state-changing operations +- Timestamps: capture once with `new Date().toISOString()` and reuse + +## Testing + +- All tests must pass before PR: `npm test` (950+ tests) +- Mock pattern: `vi.mock("iii-sdk")` with mock `sdk.trigger`, `kv.get/set/list` +- Test files go in `test/` with `.test.ts` extension +- Follow existing patterns in `test/crystallize.test.ts` for function tests + +## Current Stats (v0.9.16) + +- 53 MCP tools (8 visible by default, `AGENTMEMORY_TOOLS=all` for all) +- 128 REST endpoints +- 6 MCP resources, 3 MCP prompts +- 12 hooks, 15 skills +- 50+ iii functions +- 950+ tests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3b5188f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1119 @@ +# Changelog + +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.27] — 2026-06-07 + +Wave release closing several breaking regressions reported against v0.9.26, plus an agent-scope isolation security fix, an iii version-pin audit fix, and a benchmark scorecard correction. No breaking changes; drop-in upgrade. + +### Security + +- **`AGENTMEMORY_AGENT_SCOPE=isolated` not enforced on `mem::search` / `POST /agentmemory/search` / `memory_recall` / `recall_context`** ([#817](https://github.com/rohitg00/agentmemory/issues/817)). PR #654's isolation work covered smart-search, `/memories`, `/observations`, and `/sessions` but missed the BM25-only recall path. An isolated worker booted with `AGENT_ID=B` could read agent A's memories via the standard MCP `memory_recall` tool. `mem::search` now applies the same filter as smart-search (wildcard `agentId: "*"` bypasses, explicit `agentId` pins, isolated mode falls back to env `AGENT_ID`). `recall_context` prompt also filters its kv.list memory feed by active agent. Fail-closed: if isolated mode is on and no agent id resolves from any source, the call throws rather than dropping the filter. + +### Fixed + +- **`/graph/query` and `/graph/stats` timed out with `"Invocation stopped"` on large existing corpora** ([#814](https://github.com/rohitg00/agentmemory/issues/814), [PR #816](https://github.com/rohitg00/agentmemory/pull/816)). The v0.9.25 fix from #753 paginated the response but the unbounded `kv.list` + `kv.list` pair still ran on every call. At 75K+ nodes the response payload exceeded the iii heartbeat budget and the worker was declared dead before the new wall-clock timer could fire. Refactored `mem::graph-extract` to maintain three side-indexes (`graphNameIndex`, `graphEdgeKey`, `graphNodeDegree`) so every extract path is O(1), never O(n). The hot path now reads exclusively from a precomputed top-degree snapshot updated inline on every extract. Same `kv.list` bottleneck was also blocking `mem::graph-extract` on every observation capture, so the fix immediately speeds up the observation pipeline too. +- **All data lost on `agentmemory stop` followed by restart** ([#843](https://github.com/rohitg00/agentmemory/issues/843)). `runStop()` killed the iii engine first, then the worker. The worker's shutdown handler flushes BM25/vector snapshots and audit rows via `indexPersistence.save() -> kv.set() -> state::set`, which all routed through an iii engine that was already dead. Stop order inverted: worker first with a bumped 5s SIGTERM grace (gives a large index a real chance to commit), engine second. The "Memories persisted to disk" message now reflects actual disk state. +- **`mem::graph-snapshot-rebuild` and `mem::graph-reset` heartbeat-crashed on legacy >25K-node corpora** ([#825](https://github.com/rohitg00/agentmemory/issues/825)). Both endpoints called `kv.list` up front. At 75K nodes the response payload blocks the event loop in `JSON.parse` and the iii heartbeat starves before any wall-clock budget timer can fire. `mem::graph-snapshot-rebuild` now refuses pre-flight when no prior snapshot exists, returning `{ legacyCorpus: true }` with operator guidance unless an explicit `force: true` flag is set. `mem::graph-reset` no longer enumerates anything: it writes an empty snapshot stamped with `resetAt` and returns. Legacy rows stay as on-disk orphans but post-reset `mem::graph-extract` calls compare each name-index hit's `createdAt` against `resetAt`, so older rows are treated as not-found and a fresh node is written instead of silently reconnecting to a pre-reset orphan. +- **Multi-instance port collisions on one host** ([#750](https://github.com/rohitg00/agentmemory/issues/750), [PR #815](https://github.com/rohitg00/agentmemory/pull/815)). `--port N` only relocated REST + viewer; streams (3112) and iii engine (49134) stayed hardcoded so a second daemon collided on those WS ports regardless of `--port`. `loadConfig()` now derives streams = REST+1 and engine = REST+46023 (so the canonical 3111 → 3112/49134 default is unchanged, and `--port 3211` → 3212/49234). New `--instance N` shortcut picks a 100-port block: `--instance 1` → 3211/3212/3213/49234. Per-port env overrides (`III_STREAM_PORT`, `III_ENGINE_PORT`, `III_ENGINE_URL`) still win. +- **iii console install pulled latest engine instead of the pinned v0.11.2** (audit). `III_CONSOLE_INSTALL_CMD` ran `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh` with no version pin. A fresh first-run prompt could install a console build that talks a different protocol than the engine agentmemory pins. The install script reads a `VERSION` env var (`engine_version="${VERSION:-}"` in install.sh), so the command now passes `VERSION=${IIPINNED_VERSION}`. Render path is platform-aware too: Windows users get a PowerShell-friendly hint instead of a POSIX command they can't run. Mismatch error text updated from "reinstall the latest binary" to reference the pinned version explicitly. + +### Docs + +- **Corrected coding-agent-life-v1 P@5 numbers in the published scorecard** ([#796](https://github.com/rohitg00/agentmemory/issues/796), [PR #805](https://github.com/rohitg00/agentmemory/pull/805)). The original scorecard reported P@5 = 0.578 / 0.267, generated before [PR #562](https://github.com/rohitg00/agentmemory/pull/562) changed the P@K denominator from `topK.length` to `k`. On the current formula those numbers are mathematically impossible (math ceiling at K=5 on this corpus is 0.240). Re-scored on v0.9.26: hybrid 0.240 / R@5 1.000, grep 0.227 / R@5 0.967. Reframed lift as recall + temporal not aggregate precision; aggregate P@5 saturates on a small / single-gold corpus and can't differentiate top-tier adapters. +- **README refresh** ([PR #807](https://github.com/rohitg00/agentmemory/pull/807)). Stats line bumped to 174 files / 1,390+ tests / 258 functions. Corrected hero P@5 numbers. "New in vX.Y.Z" callout block removed since that pattern went stale every release; latest release notes link points at CHANGELOG.md. +- **`import-jsonl` users warned about Claude Code's `cleanupPeriodDays`.** Default 30-day auto-deletion of `~/.claude/projects/*.jsonl` doesn't affect hook-wired installs (each turn lands in iii state while the session is live), but fresh installs on months-old Claude Code histories silently lost anything older than 30 days. README now flags this with three workarounds: cron the import, raise `cleanupPeriodDays`, or wire hooks. + +### Added + +- **`POST /agentmemory/graph/snapshot-rebuild`** — pays the full enumeration cost once on corpora ≤ 25K nodes, persists a top-degree subgraph + aggregate counts, refuses with `{ success: false, tooLarge: true, totalNodes, ceiling }` above the safe ceiling instead of killing the worker. Strict `force: true` boolean check bypasses the legacy-corpus pre-flight refusal. +- **`POST /agentmemory/graph/reset`** — clean-restart escape hatch for legacy corpora. Enumeration-free: writes empty snapshot with `resetAt` epoch marker. Future extracts treat any pre-`resetAt` row as orphan via `createdAt` comparison. +- **`--instance N` CLI flag** — multi-daemon convenience. Picks a 100-port block off the 3111 base. Max N=50. +- **`GraphSnapshot.topDegrees`** — synchronous degree lookup keyed by nodeId so re-ranking after edge writes runs sync over numbers instead of async kv.get inside the sort comparator. +- **`GraphSnapshot.resetAt`** — ISO timestamp set by `mem::graph-reset`. Drives post-reset orphan detection in extract. +- **`GraphQueryResult.fromSnapshot`** + **`GraphQueryResult.warning`** — response fields the viewer can use to surface "served from cache" or "rebuild needed" banners instead of opaque 500s. + +### Changed + +- **REST endpoint count: 126 → 128**. `POST /agentmemory/graph/snapshot-rebuild` and `POST /agentmemory/graph/reset` added. +- **`mem::graph-stats`** now reads exclusively from the snapshot. Returns `{ ..., fromSnapshot, warning }` envelope when snapshot is absent; never returns a 500. +- **`mem::graph-query` empty-body / nodeType-only path** reads exclusively from the snapshot. `startNodeId` / `query` paths keep the live enumeration behind a 6s budget with snapshot-backed fallback on rejection. +- **`mem::search` over-fetch** triggers when agentId filtering is active (not just project/cwd). Without it, isolated-mode pages were silently underfilled when same-agent matches ranked below cross-agent ones. + +### Known limitations + +- `mem::graph-query` BFS (`startNodeId`) and text-search (`query`) paths still call `kv.list` and degrade past ~25K nodes on fresh corpora ([#828](https://github.com/rohitg00/agentmemory/issues/828)). The hot path stays safe (snapshot-only). A per-node adjacency index is the right next fix; deferred to the structured graph migration milestone ([#309](https://github.com/rohitg00/agentmemory/issues/309)). +- `/agentmemory:forget` skill still calls `memory_governance_delete` which only touches `KV.memories` and never observations ([#833](https://github.com/rohitg00/agentmemory/issues/833)). Skill rewrite + new `memory_forget` MCP tool tracked separately. +- `crypto.randomUUID()` global-only on Node <19 ([#715](https://github.com/rohitg00/agentmemory/issues/715)). Drop-in import fix tracked. + +[0.9.27]: https://github.com/rohitg00/agentmemory/compare/v0.9.26...v0.9.27 + +## [0.9.26] — 2026-06-03 + +Hotfix on top of v0.9.25. The first-run boot crashed for users without a persisted index because the load path didn't handle `undefined` returns from iii-state adapters. + +### Fixed + +- **First boot crash: `TypeError: Cannot read properties of undefined (reading 'v')`** ([#797](https://github.com/rohitg00/agentmemory/issues/797)). The sharded index load path (`loadShardedData`) checked `manifest.value !== null` before forwarding to `loadManifestData`. Some iii-state adapters return `undefined` (not `null`) for a missing key, so `undefined !== null` was true and `loadManifestData(undefined, ...)` immediately threw on `manifest.v`. Now treats both `null` and `undefined` (plus non-object values) as "no manifest" and falls through to the legacy load path. Self-healing: the next debounced save rebuilds a fresh manifest, so the crash was already cosmetic for ongoing operation — but it scared every fresh upgrader. Tests cover both undefined and wrong-shape manifest rows. + +[0.9.26]: https://github.com/rohitg00/agentmemory/compare/v0.9.25...v0.9.26 + +## [0.9.25] — 2026-06-03 + +Bug-fix wave closing every breaking regression reported against 0.9.24, plus a feature lane (graph pagination + smart-search followup diagnostic + obsidian-export hardening + sharded index persistence). Eleven issues closed. No breaking changes; drop-in upgrade. + +### Fixed + +- **Cross-provider fallback always 404'd and tripped the circuit breaker** ([#778](https://github.com/rohitg00/agentmemory/issues/778), [PR #791](https://github.com/rohitg00/agentmemory/pull/791)). `createFallbackProvider` copied the primary provider's `model` into every fallback config. With OpenAI primary + Gemini fallback, Gemini was called with `gpt-4o-mini` and 404'd on every call, tripping the circuit breaker and blocking downstream LLM ops. Each fallback now resolves its own env-driven default (`OPENAI_MODEL` / `GEMINI_MODEL` / `ANTHROPIC_MODEL` / `MINIMAX_MODEL` / `OPENROUTER_MODEL`). +- **`import-jsonl` aborted entire batch on legacy session row missing `id`** ([#775](https://github.com/rohitg00/agentmemory/issues/775), [PR #791](https://github.com/rohitg00/agentmemory/pull/791)). Existing-session branch re-keyed on `existing.id` which could be undefined for older rows. `JSON.stringify` dropped the key from the `state::set` payload, the engine returned `missing field \`key\``, and the rejection aborted the whole handler. Now re-keys on `parsed.sessionId` and backfills `existing.id`. +- **`parseSummaryXml` silently dropped summaries wrapped in markdown fences** ([#783](https://github.com/rohitg00/agentmemory/issues/783), [PR #791](https://github.com/rohitg00/agentmemory/pull/791)). DeepSeek / GPT / occasionally Anthropic responses wrap structured XML in ` ```xml ... ``` ` fences with optional conversational text. The raw payload went straight to the tag regex and returned `parse_failed`. New `stripXmlWrappers()` peels markdown fences + pre/postamble; final-merge parse path retries once on failure (matching chunk-level behavior). +- **`sdk.triggerVoid is not a function` on iii-sdk 0.11.2** ([#758](https://github.com/rohitg00/agentmemory/issues/758), [#726](https://github.com/rohitg00/agentmemory/issues/726), [PR #773](https://github.com/rohitg00/agentmemory/pull/773)). iii-sdk 0.11.2 removed `triggerVoid` in favor of `trigger({ action: TriggerAction.Void() })`. Nine call sites still used the removed API; six ran without try/catch and threw `TypeError`, breaking image lifecycle, disk-quota cleanup, vision embeddings, and observation write paths. +- **pi integration recorded every observation as "No content provided"** ([#759](https://github.com/rohitg00/agentmemory/issues/759), [PR #772](https://github.com/rohitg00/agentmemory/pull/772)). pi sent `data.input` / `data.output` but `observe.ts` reads `data.tool_input` / `data.tool_output`. Field-name mismatch left `raw.toolInput` / `raw.toolOutput` undefined; the compress pipeline produced empty observations. +- **Fresh global install refused to boot when PATH iii didn't match the runtime pin** ([#752](https://github.com/rohitg00/agentmemory/issues/752), [PR #774](https://github.com/rohitg00/agentmemory/pull/774)). The hard-pin enforcer told users to overwrite their global iii with v0.11.2, but the v0.11.2 release ships only `iii` (not `iii-init` / `iii-worker`) on some platforms. agentmemory now installs to `~/.agentmemory/bin/` and auto-falls-back to it when PATH iii mismatches the pin. User's existing iii install stays untouched. +- **`mem::obsidian-export` HTTP 500 `[object Object]` on any record missing `id`** ([#729](https://github.com/rohitg00/agentmemory/issues/729), [PR #780](https://github.com/rohitg00/agentmemory/pull/780)). `sanitize(undefined.id)` threw outside the per-record try and escaped the whole handler. Four-layer hardening: id filter, safe-array / safe-string / safe-timestamp normalizers, outer try/catch, fail-safe session sort. +- **Concurrent agent-sdk summarize chunks failed with `too_many_chunks_skipped`** ([#781](https://github.com/rohitg00/agentmemory/issues/781), [PR #782](https://github.com/rohitg00/agentmemory/pull/782)). `AGENTMEMORY_SDK_CHILD` recursion guard mutated `process.env` globally; concurrent chunks under `Promise.all` saw the marker and returned empty. Recursion guard now scoped to `AsyncLocalStorage`; the `process.env` marker stays for cross-process hook inheritance, reference-counted so overlapping calls don't race. +- **Viewer #graph tab blank on large graphs** ([#753](https://github.com/rohitg00/agentmemory/issues/753), [PR #789](https://github.com/rohitg00/agentmemory/pull/789)). `POST /agentmemory/graph/query` with `{}` on an 11k+ node corpus returned HTTP 500 `Invocation stopped`. `graph/query` now accepts `limit` / `offset`, applies a default cap (500) ranked by node degree, restricts page edges to in-page endpoints, and returns `totalNodes` / `totalEdges` / `truncated`. The viewer issues a bounded initial query, distinguishes server-error from empty-corpus, renders an actionable error banner with Retry. + +### Added + +- **Sharded BM25/vector index persistence with manifest commit/rollback** ([#762](https://github.com/rohitg00/agentmemory/issues/762), [PR #764](https://github.com/rohitg00/agentmemory/pull/764), contributed by [@Rokurolize](https://github.com/Rokurolize)). Large BM25/vector snapshots used to save as monolithic strings and failed past the iii state size ceiling. Each snapshot now writes as bounded shards under a generation-scoped prefix, with a manifest published only after all shards commit. Rollback on shard-write failure, fail-closed on length mismatch, legacy snapshot load preserved for downgrade compat. 957 LOC + 25 tests covering rollback / partial-commit / fail-closed paths. +- **Smart-search followup-rate diagnostic** ([#771](https://github.com/rohitg00/agentmemory/issues/771), [PR #786](https://github.com/rohitg00/agentmemory/pull/786)). Disambiguates retrieval bugs from reader bugs. When an agent issues a second `smart-search` within `AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS` (default 30s) and the new result set has zero overlap with the prior one, counts as a directional "first results didn't satisfy" signal. OTEL counter `agentmemory.smart_search.followup_within_window_total`, `GET /agentmemory/diagnostics/followup` REST endpoint, `agentmemory status` surface, viewer-source exclusion via `X-Agentmemory-Source: viewer` header. + +### Changed + +- **Dependency bumps + zero `npm audit` vulnerabilities** ([PR #779](https://github.com/rohitg00/agentmemory/pull/779)). `@anthropic-ai/sdk` 0.93 → 0.100.1, `tsdown` 0.20.3 → 0.21.10 (stays on Node 20 floor), `@types/node` 25.7 → 25.9, website `next` 16.2.6 → 16.2.7 + React 19.2.7. `iii-sdk` stays pinned at 0.11.2. Transitive vulns closed via overrides: `qs ^6.15.2`, `ws ^8.21.0`, `protobufjs ^7.5.8`. `npm audit` root + website: 8 → **0**. +- **REST endpoint count: 125 → 126** (new `/agentmemory/diagnostics/followup`). + +### Infrastructure + +- 1379 tests passing (up from 1314 in v0.9.24). 13 new test files covering the regressions + features above. +- Removed `website/package-lock.json` from git tracking; root `.gitignore` already forbade lockfiles. + +[0.9.25]: https://github.com/rohitg00/agentmemory/compare/v0.9.24...v0.9.25 + +## [0.9.24] — 2026-05-29 + +Hotfix on top of v0.9.23. Two bugs surfaced in the first hour after v0.9.23 hit npm: + +### Fixed + +- **`agentmemory --version` silently launched the server instead of printing the version string**. `-v` flag was reserved by `--verbose`, and no `--version` literal was handled — falling through to the default `start` command. Now `--version` (and `-V` capital, per POSIX convention) prints `VERSION` and exits 0 before any side effects (engine boot, dir mkdir, env loading). +- **iii-engine version pin was warn-only, not enforced**. When the engine on `PATH` didn't match agentmemory's pinned version (currently `v0.11.2`), the worker still booted against the mismatched engine and crashed at runtime with cryptic `state::list not found` (v0.13.0) or sandbox-everything traps (v0.11.6). `warnIfEngineVersionMismatch` renamed to `enforceEngineVersionPin` — now `p.log.error`s with the downgrade command and `process.exit(1)`. Override escape hatch via `AGENTMEMORY_III_VERSION=` env unchanged (already redefines `IIPINNED_VERSION` upstream so the comparison passes for users who knowingly run against a different engine). + +[0.9.24]: https://github.com/rohitg00/agentmemory/compare/v0.9.23...v0.9.24 + +## [0.9.23] — 2026-05-28 + +Bug-fix + integration wave. GitHub Copilot CLI joins the supported agent matrix with plugin + hooks + MCP coverage. Three silent DX bugs fixed end-to-end: graph extraction never fired on session end, `agentmemory status` reported zero memories, consolidation defaulted off even with an LLM provider configured. Five additional adapters and a clearer local-LLM story for Ollama / LM Studio users. `agentmemory connect` now points users at `npx skills add` for the native-skills install path (50+ agents). + +### Added + +- **GitHub Copilot CLI** ([PR #534](https://github.com/rohitg00/agentmemory/pull/534)). Full first-class support: `plugin/plugin.json` manifest, `hooks.copilot.json` lifecycle hooks for all 11 Copilot events with camelCase payload normalization, `agentmemory connect copilot-cli` MCP path, first-run onboarding default-select via `COPILOT_CLI` / `COPILOT_AGENT_SESSION_ID` env detection, Windows-safe `cmd.exe /d /s /c npx` wrapper. Standalone MCP transport now speaks LSP-style `Content-Length` framed JSON-RPC alongside the existing newline-delimited form so Copilot's stdio init handshake works. +- **Five new MCP adapters** ([PR #677](https://github.com/rohitg00/agentmemory/pull/677)). Warp, Cline, Continue, Zed, and Droid. `createJsonMcpAdapter` extended with `wrapperKey` (Zed uses `context_servers` instead of `mcpServers`) and `extraEntryFields` (Droid requires `type: "stdio"`). `ADAPTERS` count: 11 → 17. +- **`/agentmemory/graph/build` endpoint** ([PR #698](https://github.com/rohitg00/agentmemory/pull/698)). Backfills the knowledge graph from existing compressed observations across every session in configurable batches. Wires up the viewer's "Build Graph" button that previously returned 404. +- **11 README translations + language picker** ([PR #675](https://github.com/rohitg00/agentmemory/pull/675)). zh-CN, zh-TW, ja-JP, ko-KR, es-ES, pt-BR, fr-FR, de-DE, ru-RU, tr-TR, hi-IN. +- **`npx skills add` hint in connect output** ([PR #709](https://github.com/rohitg00/agentmemory/pull/709)). After a successful `agentmemory connect `, the CLI now prints the matching `npx skills add rohitg00/agentmemory -y` command so users get the native-skills install alongside MCP wiring. The [`skills`](https://npmjs.com/package/skills) CLI covers 50+ agents including the 5 added in PR #677. README "Other agents" section gains a dedicated subsection explaining the two-step pattern (`connect` writes MCP config, `skills add` installs the 8 SKILL.md files into the agent's native skill directory). + +### Changed + +- **Consolidation auto-enables when an LLM provider is configured** ([PR #696](https://github.com/rohitg00/agentmemory/pull/696), closes [#612](https://github.com/rohitg00/agentmemory/discussions/612)). `CONSOLIDATION_ENABLED` defaulted to `false`, so users with a working provider got compression + summarization but zero graph nodes / crystals / lessons. Now defaults `true` whenever any of `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `OPENROUTER_API_KEY` / `GEMINI_API_KEY` / `GOOGLE_API_KEY` / `MINIMAX_API_KEY` / `OPENAI_BASE_URL` is set, or `AGENTMEMORY_PROVIDER=agent-sdk`. Explicit `=false` / `AGENTMEMORY_PROVIDER=noop` still opt out. `OPENAI_API_KEY_FOR_LLM=false` honored (key scoped to embeddings). +- **Fire-and-forget telemetry hooks** ([PR #688](https://github.com/rohitg00/agentmemory/pull/688), closes [#573](https://github.com/rohitg00/agentmemory/issues/573)). Nine telemetry-only hooks (notification, post-tool-failure, post-tool-use, prompt-submit, stop, session-end, subagent-start, subagent-stop, task-completed) switched to unawaited `fetch(...).catch(() => {})` paired with `setTimeout(() => process.exit(0), 500).unref()` (1500ms for the multi-fetch `stop` + `session-end` hooks). No longer blocks Claude Code's next-prompt boundary on every assistant turn. +- **Observability defaults tamed** ([PR #686](https://github.com/rohitg00/agentmemory/pull/686), closes [#519](https://github.com/rohitg00/agentmemory/issues/519)). `iii-config.yaml` defaults: `sampling_ratio: 0.1`, `logs_console_output: false`. `findIiiConfig()` precedence reversed so `AGENTMEMORY_III_CONFIG` env > cwd > `~/.agentmemory/iii-config.yaml` > bundled. + +### Fixed + +- **Graph extraction never fires automatically** ([PR #698](https://github.com/rohitg00/agentmemory/pull/698), closes [#666](https://github.com/rohitg00/agentmemory/issues/666)). `event::session::stopped` listens on `agentmemory.session.stopped` but nothing in the codebase ever published that topic. `api::session::end` now directly calls `sdk.triggerVoid("event::session::stopped", { sessionId })` (try/catch guarded so a fan-out error doesn't fail the HTTP response). Knowledge graphs now materialize automatically when sessions end. +- **`agentmemory status` shows Memories/Observations = 0** ([PR #698](https://github.com/rohitg00/agentmemory/pull/698), closes [#666](https://github.com/rohitg00/agentmemory/issues/666)). CLI fetched `/agentmemory/export` for counts but that endpoint times out (>5s) on iii-engine's file-based KV under concurrent `kv.list()`. Switched to `/memories?count=true` (count-only, constant-time) + sum of `sessions[].observationCount`. +- **Hooks send full filesystem path as `project` instead of repo basename** ([PR #687](https://github.com/rohitg00/agentmemory/pull/687), closes [#474](https://github.com/rohitg00/agentmemory/issues/474)). Native sessions, replay-import, and `memory_lesson_save` all use repo basename — the path/basename mismatch silently filtered out the bulk of relevant lessons from auto-injected context. New `resolveProject(cwd?)` helper: `AGENTMEMORY_PROJECT_NAME` env > `git rev-parse --show-toplevel` basename > `basename(cwd)`. Applied to 9 hooks. +- **Cross-project memory leakage** ([PR #662](https://github.com/rohitg00/agentmemory/pull/662)). Memories created in one project leaking into recall on another. Wildcard guard pattern `(!project || !m.project || m.project === project)` preserves backward-compat for legacy unscoped memories while scoping new ones strictly. +- **Graph parser tolerates reordered XML attributes** ([PR #685](https://github.com/rohitg00/agentmemory/pull/685), closes [#635](https://github.com/rohitg00/agentmemory/issues/635)). Order-dependent regex replaced with order-independent `parseAttrs` helper. Self-closing trailing-`/` handling preserved. +- **Defensive null guards on `Memory.sessionIds`** ([PR #684](https://github.com/rohitg00/agentmemory/pull/684)). Older exports + hand-edited dumps can omit this field; now treated as empty array on read. +- **Vector index Buffer slice metadata round-trip** ([PR #683](https://github.com/rohitg00/agentmemory/pull/683), closes [#587](https://github.com/rohitg00/agentmemory/issues/587), [#584](https://github.com/rohitg00/agentmemory/issues/584)). `byteOffset` + `byteLength` preserved on `Buffer.from` and `Float32Array` construction so base64 round-trips don't corrupt vectors stored as slices. +- **Slots HTTP triggers return 503 not 500** ([PR #682](https://github.com/rohitg00/agentmemory/pull/682), closes [#678](https://github.com/rohitg00/agentmemory/issues/678)). Feature-flag-disabled responses now use the documented 503 shape with `enableHow` + `docsHref`. `.env` propagation fixed (now reads via `getEnvVar()` not raw `process.env`). +- **`agentmemory doctor` points to correct iii install URL** ([PR #681](https://github.com/rohitg00/agentmemory/pull/681)). +- **Separate `OPENAI_EMBEDDING_BASE_URL` + `OPENAI_EMBEDDING_API_KEY`** ([PR #503](https://github.com/rohitg00/agentmemory/pull/503)). Lets users route embeddings and LLM calls to different OpenAI-compatible endpoints (e.g. cloud OpenAI for embeddings + local LM Studio for LLM, or vice versa). +- **Viewer Memories tab sorts newest first** ([PR #701](https://github.com/rohitg00/agentmemory/pull/701), closes [#674](https://github.com/rohitg00/agentmemory/issues/674)). Memories list rendered in KV-insertion order, hiding just-saved entries at the bottom of long lists. `loadMemories()` now sorts on `createdAt` desc (fallback `updatedAt`) before items reach state — matches the `localeCompare` pattern Sessions / Metrics tabs already use. + +### Docs + +- **Local-LLM section in README** ([PR #697](https://github.com/rohitg00/agentmemory/pull/697), closes [#671](https://github.com/rohitg00/agentmemory/discussions/671)). Dedicated "Local models (Ollama / LM Studio / vLLM)" subsection with copy-paste configs, model-pick table (qwen2.5-coder:7b, llama3.2:3b, mistral:7b-instruct, deepseek-r1:7b), and reasoning-model empty-content callout. Existing OpenAI-compatible support was buried in a one-line env comment. +- **Three pipeline layers, not three primitives** ([PR #690](https://github.com/rohitg00/agentmemory/pull/690)). Website + README copy: the three primitives are worker / function / trigger (iii). HOOKS / RECALL / CONSOLIDATE are pipeline layers. Section anchor renamed `#primitives` → `#stack` to match the nav label. + +### Infrastructure + +- Hook scripts now bundle per-entry instead of sharing chunks, so `plugin/scripts/_project-*.mjs` hashed artifacts no longer churn the diff on every build. +- `AGENTS.md` "Hook Scripts" section documents the two patterns (context-injecting vs telemetry-only) and the 500ms / 1500ms exit-delay rule for single vs multi-fetch hooks. +- Test suite: 1271 → 1291 (+20 new tests covering project basename resolver, fire-and-forget wiring, consolidation default behavior, session-end → graph extraction, the graph-build endpoint, and the viewer memories sort). + +[0.9.23]: https://github.com/rohitg00/agentmemory/compare/v0.9.22...v0.9.23 + +## [0.9.22] — 2026-05-26 + +Stability + ecosystem wave. Three install-broken bugs (`npm install` ERESOLVE, non-OpenAI base URLs, broken Claude bridge path) closed. Six runtime bugs from active users fixed end-to-end. Three new agent integrations (Qwen Code, Antigravity, Kiro). New `AGENT_ID` scope for multi-agent setups. Port mapping documented. + +### Fixed + +- **`npm install` ERESOLVE on fresh install** ([PR #649](https://github.com/rohitg00/agentmemory/pull/649), closes [#631](https://github.com/rohitg00/agentmemory/issues/631)). `@anthropic-ai/sdk` bumped from `^0.39.0` to `^0.93.0` so `claude-agent-sdk`'s peer is satisfied. Verified clean install with only the published `dependencies` block. + +- **Non-OpenAI base URLs silent-404** ([PR #649](https://github.com/rohitg00/agentmemory/pull/649), closes [#646](https://github.com/rohitg00/agentmemory/issues/646), [#628](https://github.com/rohitg00/agentmemory/issues/628)). `buildChatUrl` + `buildEmbeddingUrl` no longer blindly prepend `/v1/`. DeepSeek, SiliconFlow, Zhipu (`/api/paas/v4`), vLLM, LM Studio, Ollama all resolve correctly. + +- **`CLAUDE_MEMORY_BRIDGE` writes to a path Claude Code reads** ([PR #649](https://github.com/rohitg00/agentmemory/pull/649), closes [#625](https://github.com/rohitg00/agentmemory/issues/625)). Slug now preserves the leading `-` on POSIX absolute paths and drops the spurious `/memory/` subdir, matching `~/.claude/projects//MEMORY.md`. + +- **OpenAI provider reads `reasoning_content` for thinking models** ([PR #648](https://github.com/rohitg00/agentmemory/pull/648), closes [#627](https://github.com/rohitg00/agentmemory/issues/627)). DeepSeek V4 / Qwen3 / GLM / Kimi return `message.reasoning_content`. Previously only `message.reasoning` was checked — compress silently failed every call and tripped the circuit breaker. + +- **`agentmemory stop` reaps the worker process** ([PR #648](https://github.com/rohitg00/agentmemory/pull/648), closes [#640](https://github.com/rohitg00/agentmemory/issues/640), [#474](https://github.com/rohitg00/agentmemory/issues/474)). Worker pid is written to `~/.agentmemory/worker.pid` on boot; `stop` signals both engine + worker. + +- **OpenCode plugin implicit-creates the session on first observation** ([PR #648](https://github.com/rohitg00/agentmemory/pull/648), closes [#638](https://github.com/rohitg00/agentmemory/issues/638)). `mem::observe` creates the session row when one doesn't exist. No more orphan observations or `Session not found for summarize`. + +- **OpenCode plugin zero-config auto-context injection** ([PR #648](https://github.com/rohitg00/agentmemory/pull/648), closes [#431](https://github.com/rohitg00/agentmemory/issues/431)). `POST /session/start` context is cached per-session; the existing `experimental.chat.system.transform` hook reads from the cache. + +- **Viewer graph settles on 1000+ node graphs** ([PR #648](https://github.com/rohitg00/agentmemory/pull/648), closes [#563](https://github.com/rohitg00/agentmemory/issues/563)). Tick-decayed damping, per-node velocity cap, raf park on quiescence. Mousedown / wheel / zoom / recenter re-wake the parked loop. + +- **`/memories` + `/export` paginate** ([PR #648](https://github.com/rohitg00/agentmemory/pull/648), closes [#544](https://github.com/rohitg00/agentmemory/issues/544)). New `?count=true` and `?limit=N&offset=M` on `/memories`. `/export` forwards `?maxSessions` + `?offset`. Stops large corpora (8K+ memories) from timing out at the iii-engine invocation boundary. + +- **Claude Code drops the MCP server silently** ([PR #650](https://github.com/rohitg00/agentmemory/pull/650), closes [#510](https://github.com/rohitg00/agentmemory/issues/510)). `plugin/.mcp.json` env block uses `${VAR:-default}` form. Unset required vars no longer fail config parse. + +- **Full 51-tool MCP surface by default** ([PR #650](https://github.com/rohitg00/agentmemory/pull/650), closes [#553](https://github.com/rohitg00/agentmemory/issues/553)). `getVisibleTools()` default flipped from `core` (8) to `all` (51) to match what every plugin manifest advertises. `AGENTMEMORY_TOOLS=core` still gives the lean set. + +- **Connect adapters write `${VAR:-default}` env block** ([PR #650](https://github.com/rohitg00/agentmemory/pull/650)). `agentmemory connect` for Claude Code / Cursor / Gemini CLI / Windsurf writes the same default form. + +- **Hermes `memory status` no longer reports the plugin as Missing** ([PR #643](https://github.com/rohitg00/agentmemory/pull/643), closes [#520](https://github.com/rohitg00/agentmemory/issues/520)). Hermes plugin seeds `AGENTMEMORY_URL` to `http://localhost:3111` at import. Works for systemd-managed agentmemory where the env file is loaded via `EnvironmentFile=` and never reaches the interactive shell. + +- **Deleted memories cleared from BM25 + vector indices** ([PR #636](https://github.com/rohitg00/agentmemory/pull/636) by [@abhinav-m22](https://github.com/abhinav-m22), closes [#632](https://github.com/rohitg00/agentmemory/issues/632)). `SearchIndex.remove()` added and called from every delete path. Snapshot flushed synchronously so a SIGKILL between mutation + debounce can't resurrect deleted entries. + +- **PostToolUse hook reads `tool_response`, falls back to `tool_output`** ([PR #561](https://github.com/rohitg00/agentmemory/pull/561) by [@faraz152](https://github.com/faraz152), closes [#539](https://github.com/rohitg00/agentmemory/issues/539)). Claude Code's PostToolUse payload uses `tool_response`. Now reads `tool_response ?? tool_output` so legacy integrations keep working. + +- **iii-sdk pinned to exact `0.11.2`** ([PR #567](https://github.com/rohitg00/agentmemory/pull/567), closes [#555](https://github.com/rohitg00/agentmemory/issues/555)). `iii-sdk@0.11.6` introduced a routing regression where every `/agentmemory/*` route returned 404. Pin removes the caret. + +- **OpenAI provider sends explicit `stream: false`** ([PR #526](https://github.com/rohitg00/agentmemory/pull/526) by [@Ptah-CT](https://github.com/Ptah-CT)). Some OpenAI-compatible proxies default to `text/event-stream` when `stream` is absent. + +- **Viewer search uses NFKC normalisation for CJK / fullwidth input** ([PR #542](https://github.com/rohitg00/agentmemory/pull/542) by [@kaushalrog](https://github.com/kaushalrog)). + +- **Viewer splash shows actual bound viewer port** ([PR #560](https://github.com/rohitg00/agentmemory/pull/560) by [@Tanmay-008](https://github.com/Tanmay-008), closes [#521](https://github.com/rohitg00/agentmemory/issues/521)). `/agentmemory/livez` now returns `viewerPort` + `viewerSkipped`. + +- **Viewer tab bar height stable across tab switches** ([PR #325](https://github.com/rohitg00/agentmemory/pull/325) by [@hungtd119](https://github.com/hungtd119), closes [#324](https://github.com/rohitg00/agentmemory/issues/324)). + +- **Graph parser accepts self-closing `` tags** ([PR #494](https://github.com/rohitg00/agentmemory/pull/494) by [@Rex57](https://github.com/Rex57), closes [#492](https://github.com/rohitg00/agentmemory/issues/492)). + +- **Plugin MCP server inherits remote/auth env** ([PR #386](https://github.com/rohitg00/agentmemory/pull/386) by [@LaplaceYoung](https://github.com/LaplaceYoung), closes [#375](https://github.com/rohitg00/agentmemory/issues/375)). + +- **`@agentmemory/mcp` rejects literal `${VAR}` placeholders**. Any `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` value of the form `${...}` is treated as unset and falls back to `http://localhost:3111`. + +- **Codex `stop` hook closes session** ([PR #579](https://github.com/rohitg00/agentmemory/pull/579), closes [#493](https://github.com/rohitg00/agentmemory/issues/493)). `Stop` was missing from the Codex bundle; session never got marked completed. + +- **Claude Code `--with-hooks` works for MCP-standalone users** ([PR #581](https://github.com/rohitg00/agentmemory/pull/581), closes [#508](https://github.com/rohitg00/agentmemory/issues/508)). + +### Added + +- **`AGENT_ID` multi-agent memory isolation** ([PR #654](https://github.com/rohitg00/agentmemory/pull/654), closes [#554](https://github.com/rohitg00/agentmemory/issues/554)). Optional `AGENT_ID` env tags every Session / RawObservation / CompressedObservation / Memory. `AGENTMEMORY_AGENT_SCOPE=isolated` (opt-in; `shared` default) also filters every recall path (`mem::smart-search`, `/memories`, `/observations`, `/sessions`). Per-call overrides via request body + `?agentId=` / `?agentId=*` query params. `?includeOrphans=true` surfaces pre-tag rows. + +- **Qwen Code connect adapter** ([PR #651](https://github.com/rohitg00/agentmemory/pull/651), closes [#647](https://github.com/rohitg00/agentmemory/issues/647)). `agentmemory connect qwen` writes the standard `mcpServers` block to `~/.qwen/settings.json`. + +- **Antigravity connect adapter** ([PR #651](https://github.com/rohitg00/agentmemory/pull/651), closes [#614](https://github.com/rohitg00/agentmemory/issues/614)). Replacement for Gemini CLI (sunset 2026-06-18). Writes `mcp_config.json` to the platform-specific User dir. + +- **Kiro connect adapter** ([PR #651](https://github.com/rohitg00/agentmemory/pull/651), closes [#618](https://github.com/rohitg00/agentmemory/issues/618)). Writes user-level `~/.kiro/settings/mcp.json`. + +- **Cost-aware model selection** ([PR #654](https://github.com/rohitg00/agentmemory/pull/654), closes [#613](https://github.com/rohitg00/agentmemory/issues/613)). Runtime warning when `OPENROUTER_MODEL` matches the premium pattern. README cost-tier table with measured workload data. Suppress via `AGENTMEMORY_SUPPRESS_COST_WARNING=1`. + +- **Pluggable benchmark harness** ([PR #562](https://github.com/rohitg00/agentmemory/pull/562)). New `eval/` directory with the `coding-agent-life-v1` corpus (15 sessions + 15 graded queries) and three adapters (grep, OpenAI embeddings + cosine, agentmemory hybrid). LongMemEval support. Sandboxed agentmemory + iii-engine on alt ports via `eval/scripts/sandbox.sh`. Dev-only — no runtime impact. + +- **`agentmemory connect codex --with-hooks` opt-in flag** ([PR #564](https://github.com/rohitg00/agentmemory/pull/564), closes [#509](https://github.com/rohitg00/agentmemory/issues/509)). Workaround for [openai/codex#16430](https://github.com/openai/codex/issues/16430). + +- **Cross-platform CI matrix** ([PR #556](https://github.com/rohitg00/agentmemory/pull/556)). Ubuntu + macOS × Node 20 + 22, `paths-ignore`, per-branch concurrency cancellation. + +### Docs + +- **Port mapping table** ([PR #651](https://github.com/rohitg00/agentmemory/pull/651), closes [#629](https://github.com/rohitg00/agentmemory/issues/629)). `3111` REST / `3112` streams / `3113` viewer / `49134` engine WS + env overrides + stale-process cleanup recipe. + +- **Pairings recipe** ([PR #641](https://github.com/rohitg00/agentmemory/pull/641)). `docs/recipes/pairings.md` covers stacking agentmemory with codegraph, Understand Anything, and Graphify. + +- **Multi-agent README section** ([PR #654](https://github.com/rohitg00/agentmemory/pull/654)). `AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE` semantics, per-endpoint behavior table. + +- **Supply-chain policy in SECURITY.md** ([PR #654](https://github.com/rohitg00/agentmemory/pull/654), closes [#540](https://github.com/rohitg00/agentmemory/issues/540)). Explains why no lockfile is committed (`dist/` ships pre-built) and what monitoring exists. + +- **README "Config File" section + Windows path** ([PR #321](https://github.com/rohitg00/agentmemory/pull/321) by [@aqilaziz](https://github.com/aqilaziz), closes [#293](https://github.com/rohitg00/agentmemory/issues/293)). + +- **README sudo install hint for `EACCES`** ([PR #454](https://github.com/rohitg00/agentmemory/pull/454) by [@kedar-1](https://github.com/kedar-1)). + +### Infrastructure + +- 108 test files, **1171 tests pass**. +- Agent count: 8 → 11 (claude-code, codex, cursor, gemini-cli, qwen, antigravity, kiro, openclaw, hermes, pi, openhuman). + +## [0.9.21] — 2026-05-19 + +Quality + integration wave. Headline: native OpenCode plugin with full Claude Code hook parity ([#237](https://github.com/rohitg00/agentmemory/pull/237) by [@cl0ckt0wer](https://github.com/cl0ckt0wer)). Ten more PRs alongside: `memory_recall` returning the wrong shape, env-file `AGENTMEMORY_DROP_STALE_INDEX` silently ignored, hook scripts crashing on Windows usernames with spaces, viewer search inputs interrupting CJK IME composition, large sessions silently failing at the LLM context limit, lessons invisible to smart-search, Hermes plugin manifest missing hooks, cli onboarding crashing in non-TTY contexts, rebuildIndex blocking boot on large corpora, 25h embed-loop bottleneck during rebuild, and the v0.9.19 iii-console installer workaround can come out now that upstream is fixed. + +### Added + +- **OpenCode plugin with 22 auto-capture hooks** ([PR #237](https://github.com/rohitg00/agentmemory/pull/237) by [@cl0ckt0wer](https://github.com/cl0ckt0wer), closes [#236](https://github.com/rohitg00/agentmemory/issues/236) + [#244](https://github.com/rohitg00/agentmemory/issues/244)). Complete OpenCode plugin in `plugin/opencode/` matching Claude Code hook parity. Covers session lifecycle (8 hooks), messages (3), tool lifecycle (2), part tracking, permissions, task tracking, plus a two-layer enrichment pipeline (memory context on first turn, file enrichment on subsequent turns) and two slash commands (`/recall`, `/remember`). Full gap analysis in `plugin/opencode/README.md`. + +### Fixed + +- **`memory_recall` endpoint + format/token_budget forwarding** ([PR #516](https://github.com/rohitg00/agentmemory/pull/516) by [@serhiizghama](https://github.com/serhiizghama), closes [#507](https://github.com/rohitg00/agentmemory/issues/507) + [#440](https://github.com/rohitg00/agentmemory/issues/440)). MCP `memory_recall` always returned compact mode and dropped `format` + `token_budget` params. Two root causes fixed: standalone shim routed through `/agentmemory/smart-search` instead of `/agentmemory/search`, and the local-fallback path didn't read either param. Now routes correctly, forwards both params end-to-end, defaults `format` to `"full"` matching the MCP schema. + +- **env-file `AGENTMEMORY_DROP_STALE_INDEX` flag now honored** ([PR #461](https://github.com/rohitg00/agentmemory/pull/461) by [@honor2030](https://github.com/honor2030), closes [#456](https://github.com/rohitg00/agentmemory/issues/456)). Setting the flag in `~/.agentmemory/.env` was silently ignored because the boot path read `process.env` directly. New `isDropStaleIndexEnabled()` helper reads merged env. Combined with [#455](https://github.com/rohitg00/agentmemory/issues/455) + [#469](https://github.com/rohitg00/agentmemory/issues/469) reports, this is the unblock path for the stale-index server-crash recovery loop. + +- **Windows hook scripts quote plugin paths correctly** ([PR #487](https://github.com/rohitg00/agentmemory/pull/487) by [@honor2030](https://github.com/honor2030), closes [#477](https://github.com/rohitg00/agentmemory/issues/477)). Hook command strings referenced `${CLAUDE_PLUGIN_ROOT}/scripts/*.mjs` without quotes — Windows users with spaces in their username had every hook crash. Quotes added + regression test. + +- **Viewer search inputs honor IME composition** ([PR #517](https://github.com/rohitg00/agentmemory/pull/517) by [@jonathanzhan1975](https://github.com/jonathanzhan1975)). CJK users typing in the viewer's search inputs hit mid-character interruption — every keystroke fired the `oninput=` re-render handler, breaking IME composition mid-syllable. New `bindImeSafeSearch` helper defers re-render until `compositionend`. + +- **Chunk large sessions to fit LLM context window** ([PR #472](https://github.com/rohitg00/agentmemory/pull/472) by [@efenex](https://github.com/efenex)). Sessions with >7000 observations silently failed at the LLM provider's context limit — the consolidation pipeline silently skipped the session. New chunking splits oversized sessions across multiple compress calls + restitches the narrative via a `REDUCE_SYSTEM` prompt. Legacy single-call path preserved when obs count is under the chunk size. Backfill script under `scripts/` for users hitting the pre-fix bug. + +- **Surface lessons in smart-search + diagnose tally** ([PR #473](https://github.com/rohitg00/agentmemory/pull/473) by [@efenex](https://github.com/efenex)). Closes the lesson round-trip with [#458](https://github.com/rohitg00/agentmemory/pull/458) (lessons auto-injected into `mem::context`): lessons are now also returned alongside hybrid search results in a separate `lessons` field on `smart-search`, and the `diagnose` health surface tallies per-store counts so the trust-shock pattern (save succeeds, recall empty, diagnose says 0) goes away. + +- **Declare all Hermes plugin hooks** ([PR #486](https://github.com/rohitg00/agentmemory/pull/486) by [@honor2030](https://github.com/honor2030)). The Hermes `plugin.yaml` manifest only declared 3 of the 6 implemented hooks. All 6 now declared (`prefetch`, `sync_turn`, `on_session_end`, `on_pre_compress`, `on_memory_write`, `system_prompt_block`). + +- **`rebuildIndex` non-blocking on boot** ([PR #500](https://github.com/rohitg00/agentmemory/pull/500) by [@efenex](https://github.com/efenex)). Boot path previously `await`-ed `rebuildIndex(kv)`, so the viewer + later boot steps stalled — on large corpora this was 25h+ of blocked startup. Replaced with `void rebuildIndex(kv).then(...).catch(...)` so the rebuild runs in the background. + +- **Batched embed calls in `rebuildIndex` (25h → 3h on large corpora)** ([PR #504](https://github.com/rohitg00/agentmemory/pull/504) by [@efenex](https://github.com/efenex)). The rebuild loop made one embed call per observation, paying full HTTP RTT per item. New `vectorIndexAddBatchGuarded` helper batches embeds (default 32, configurable via `REBUILD_EMBED_BATCH_SIZE`) and try/catches per-item failures. Measured 25h → 3h on a 250k-observation corpus. + +- **CLI skips onboarding prompts without a tty** ([PR #491](https://github.com/rohitg00/agentmemory/pull/491) by [@honor2030](https://github.com/honor2030)). Onboarding prompts crashed in non-interactive contexts (CI, `docker run -d`, piped input). New guard short-circuits with sensible defaults when stdin/stdout aren't TTYs or `CI=1`. + +### Changed + +- **Drop iii-console installer `--next` workaround** ([PR #546](https://github.com/rohitg00/agentmemory/pull/546)). v0.9.19 routed first-run iii-console install through `bash -s -- --next` to dodge an upstream tag-prefix bug at [iii-hq/iii#1652](https://github.com/iii-hq/iii/issues/1652). Upstream [iii-hq/iii#1660](https://github.com/iii-hq/iii/pull/1660) shipped 2026-05-19; `install.iii.dev/console/main/install.sh` is a CDN proxy serving upstream main HEAD so the fix is live without an iii release tag. Reverted to canonical bare `curl ... | sh`. + +### Infrastructure + +- 95 test files (was 92), **1067 tests pass** (was 1038) on `chore(release): v0.9.21`. +- Bundles 11 PRs: 1 contributor feature + 9 bug fixes across MCP / hooks / viewer / summarize / lessons / Hermes / rebuildIndex / CLI + 1 upstream-installer revert. +- New contributors landing first PRs this release: [@cl0ckt0wer](https://github.com/cl0ckt0wer), [@serhiizghama](https://github.com/serhiizghama), [@jonathanzhan1975](https://github.com/jonathanzhan1975). + +[0.9.21]: https://github.com/rohitg00/agentmemory/compare/v0.9.20...v0.9.21 + +## [0.9.20] — 2026-05-18 + +Hotfix: revert the Codex Stop → session-end chain shipped in v0.9.19. + +### Fixed + +- **Revert Codex Stop hook session-end chain** ([PR #501](https://github.com/rohitg00/agentmemory/pull/501) by [@Rex57](https://github.com/Rex57), reverts v0.9.19's [#495](https://github.com/rohitg00/agentmemory/pull/495), re-opens [#493](https://github.com/rohitg00/agentmemory/issues/493)). Post-merge field-testing surfaced the underlying issue: Codex `Stop` fires before the overall conversation is truly finished — multiple Stops bracket each assistant turn within one session. Chaining `session-end.mjs` from Stop marked sessions completed too early, with later observations still arriving against an `endedAt`-stamped record. Restored to summarize-only Stop; the SessionEnd-shaped solution stays open as [#493](https://github.com/rohitg00/agentmemory/issues/493). + +[0.9.20]: https://github.com/rohitg00/agentmemory/compare/v0.9.19...v0.9.20 + +## [0.9.19] — 2026-05-18 + +Feature + hardening wave. Sessions now link to the git commits they shipped (forward + reverse lookup, REST + MCP surfaces). OpenAI provider transport collapses into one shared module with auto-detected Azure URL style (legacy `/openai/deployments/` and v1 `/openai/v1` both supported). Graph retrieval switches from BFS to Dijkstra over the weighted edge graph. Codex Stop hook chains session-end. Plugin MCP server inherits `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` from the shell. Point fix routes the bundled iii-console installer around an upstream tag-prefix bug. 1007+ tests pass. + +### Added + +- **Session-to-commit linking** ([PR #498](https://github.com/rohitg00/agentmemory/pull/498)). New `KV.commits` namespace keyed by full SHA holds `CommitLink` records (sha, shortSha, branch, repo, message, author, authoredAt, files, sessionIds). `Session.commitShas[]` provides the forward back-reference. REST: `POST /agentmemory/session/commit` upserts links (merges `sessionIds` on re-link, preserves `linkedAt`); `GET /agentmemory/commits/:sha` and `GET /agentmemory/commits` round-trip. MCP: `memory_commit_lookup` and `memory_commits` tools. Post-commit hook auto-captures the link on every commit in the working directory. Closes the loop on "what session wrote this code" / "what commits did this session ship" without leaving agentmemory. + +- **Azure OpenAI v1 URL pattern auto-detection** ([PR #462](https://github.com/rohitg00/agentmemory/pull/462), closes [#371](https://github.com/rohitg00/agentmemory/issues/371)). Both the LLM and embedding providers now route through `_openai-shared.ts` and auto-detect Azure URL style: + - `OPENAI_BASE_URL=https://r.openai.azure.com/openai/deployments/` → legacy URL pattern, `api-version` query param via `OPENAI_API_VERSION` (default `2024-08-01-preview`). + - `OPENAI_BASE_URL=https://r.openai.azure.com` (bare host, or `/openai`, or `/openai/v1`) → v1 GA pattern, `/openai/v1/`, deployment name carried in the request body as `model`. + - Net effect: Azure embeddings work for the first time (LLM-side Azure shipped in v0.9.17; embedding was still hardcoded to `/v1/embeddings` + Bearer). Closes [#199](https://github.com/rohitg00/agentmemory/issues/199) (consolidation) as superseded. + +### Changed + +- **Graph retrieval: BFS → Dijkstra over weighted edges** ([PR #463](https://github.com/rohitg00/agentmemory/pull/463), closes [#328](https://github.com/rohitg00/agentmemory/issues/328) filed by [@Tanmay-008](https://github.com/Tanmay-008) with benchmark numbers showing the BFS edge-count semantics + O(n) `Array.shift()` profile). Memory graph edges carry weights 0.1–1.0; BFS visited by edge-count regardless, so a one-hop weak edge ranked the same as a two-hop strong chain. Dijkstra over `cost = 1/max(weight, 0.01)` selects weight-optimal paths instead. Adjacency map built once in O(V+E) and min-heap dequeue at O(log V) replace the prior O(V·E) + O(n) `Array.shift()` profile. `maxDepth` semantics preserved (still edge-count bound). The `startNode` path is excluded from returned paths so the dedicated `score=1.0` fallback loop in `searchByEntities` fires as designed (regression catch from the inline review). + +- **Codex Stop hook chains session-end** ([PR #495](https://github.com/rohitg00/agentmemory/pull/495) by [@Rex57](https://github.com/Rex57), fixes [#493](https://github.com/rohitg00/agentmemory/issues/493) also filed by [@Rex57](https://github.com/Rex57)). `hooks.codex.json` Stop now runs `stop.mjs` followed by `session-end.mjs`. Codex doesn't ship a separate `SessionEnd` lifecycle event, so the session would otherwise stay open after the user terminated the Codex session. Tests assert both commands appear in the Stop chain. + +- **Plugin MCP entry inherits shell env via passthrough** ([PR #460](https://github.com/rohitg00/agentmemory/pull/460), closes [#375](https://github.com/rohitg00/agentmemory/issues/375) filed by [@anthony-spruyt](https://github.com/anthony-spruyt)). `plugin/.mcp.json` and `AGENTMEMORY_MCP_BLOCK` (the template `agentmemory connect ` writes into `~/.claude.json`, `~/.cursor/mcp.json`, etc.) now declare `env: { AGENTMEMORY_URL: "${AGENTMEMORY_URL}", AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET}" }`. The MCP host substitutes shell values at server launch. When the vars are unset, the host passes empty strings; the standalone shim's `process.env["AGENTMEMORY_URL"] || "http://localhost:3111"` falls back to localhost. One wired entry now covers both local and remote (k8s / reverse-proxied) deployments without `/doctor` "duplicate server" warnings. + +### Fixed + +- **`ensureIiiConsole()` install path** (`src/cli.ts`). The upstream `install.iii.dev/console/main/install.sh` script's jq predicate filters releases with `startswith("v")` while `iii-hq/iii` tags as `iii/v0.12.0`. The script bails with `no stable iii release found` on every fresh install. Switched the install command to `curl ... | bash -s -- --next` until upstream patches the script — the `--next` codepath uses a regex on `-next.` without the buggy `startswith` constraint, so it succeeds against the same tag set. Inline comment documents the upstream bug + revert condition. + +### Infrastructure + +- 91 test files, 1007 tests pass on `chore(release): v0.9.19`. +- Bundles five PRs ([#460](https://github.com/rohitg00/agentmemory/pull/460), [#462](https://github.com/rohitg00/agentmemory/pull/462), [#463](https://github.com/rohitg00/agentmemory/pull/463), [#495](https://github.com/rohitg00/agentmemory/pull/495), [#498](https://github.com/rohitg00/agentmemory/pull/498)) plus the inline iii-console installer workaround into one patch release. + +[0.9.19]: https://github.com/rohitg00/agentmemory/compare/v0.9.18...v0.9.19 + +## [0.9.18] — 2026-05-17 + +Hardening + DX wave. Five fixes land together: lessons now flow into the auto-inject context payload (closes a half-finished loop from earlier releases — see #381 / #457), the viewer drops `data:` from its `img-src` CSP by self-hosting its favicon, the filesystem watcher redacts PEM private-key blocks and standalone JWTs before transport, the mcp-standalone livez probe gets a dependency-injection seam that kills a flaky test, and the OpenAI timeout precedence is documented + tightened (strict integer parse, `OPENAI_TIMEOUT_MS` keeps its v0.9.17 meaning as an alias of the global `AGENTMEMORY_LLM_TIMEOUT_MS`). 1007/1007 tests pass. + +### Added + +- **Lessons auto-injected into `mem::context` payload** ([PR #458](https://github.com/rohitg00/agentmemory/pull/458), closes [#457](https://github.com/rohitg00/agentmemory/issues/457), surfaced in discussion [#381](https://github.com/rohitg00/agentmemory/discussions/381)). Lessons were generated + stored but only retrievable via an explicit `memory_lesson_recall` MCP call — agents rarely thought to invoke it, so the loop was half-done. `mem::context` now reads `KV.lessons` alongside slots + profile, ranks by `(project-relevance × confidence)` (project-scoped lessons get a 1.5× boost), filters tombstoned + cross-project entries, caps at top-10, and emits a `## Lessons Learned` block competing fairly for the token budget. Block recency tracks the most-recent `lastReinforcedAt || updatedAt`, so hot lessons survive when budget tightens. + +- **Self-hosted viewer favicon** ([PR #452](https://github.com/rohitg00/agentmemory/pull/452), closes [#447](https://github.com/rohitg00/agentmemory/issues/447)). The viewer's inline-SVG `data:` favicon (added in #313) required `data:` in `img-src` — a broader allowance than the viewer actually needed. The favicon now lives at `/favicon.svg` served by the viewer with `Content-Type: image/svg+xml` and `Cache-Control: public, max-age=3600`; build script copies the asset into `dist/viewer/` alongside `index.html`. CSP reverts to bare `img-src 'self'`. + +### Changed + +- **`OPENAI_TIMEOUT_MS` is now an alias of `AGENTMEMORY_LLM_TIMEOUT_MS`** ([PR #453](https://github.com/rohitg00/agentmemory/pull/453), closes [#446](https://github.com/rohitg00/agentmemory/issues/446)). v0.9.17 shipped `OPENAI_TIMEOUT_MS` as the OpenAI-scoped knob, then [PR #379](https://github.com/rohitg00/agentmemory/pull/379) introduced the global `AGENTMEMORY_LLM_TIMEOUT_MS` shared across all raw-fetch providers. The OpenAI provider now resolves them in precedence order: `OPENAI_TIMEOUT_MS` → `AGENTMEMORY_LLM_TIMEOUT_MS` → `60_000ms` default. v0.9.17 configs keep working unchanged; new configs should prefer the global. The provider's request also moved onto the shared `fetchWithTimeout` helper that owns AbortController + `clearTimeout` cleanup for every raw-fetch path (minimax, openrouter, gemini, embedding providers). + +- **Strict integer parse for timeout env vars** (PR #453, CodeRabbit catch). `parsePositiveInt` rejects values like `"30ms"`, `"1_000"`, `"60s"`, `"30abc"`, `"-30"`, `"0"` via `/^\d+$/` (after trim) instead of letting `parseInt`'s lenience silently swallow trailing units / underscores / signs as a number. Malformed values fall back to the 60s default with no surprise truncation. + +### Fixed + +- **Filesystem watcher redacts PEM private-key blocks + standalone JWTs in previews** ([PR #450](https://github.com/rohitg00/agentmemory/pull/450), closes [#448](https://github.com/rohitg00/agentmemory/issues/448)). Continues the redaction surface opened in [PR #332](https://github.com/rohitg00/agentmemory/pull/332). PEM blocks (`-----BEGIN ... PRIVATE KEY-----` through `-----END ... PRIVATE KEY-----`, including encrypted, RSA, EC, DSA, OpenSSH, PGP variants) get a state-machine pass that replaces the whole block with a single `[REDACTED ... PRIVATE KEY]` marker; standalone JWT-shaped tokens (three base64url segments separated by dots, length ≥ ~32 chars) are masked to their last 4 chars. Both run before any transport-layer write. + +- **mcp-standalone livez probe DI seam kills the test flake** ([PR #451](https://github.com/rohitg00/agentmemory/pull/451), closes [#449](https://github.com/rohitg00/agentmemory/issues/449)). The standalone shim's livez probe used a fixed `fetch` against `localhost:3111` which made the test suite depend on no other agentmemory instance running on the host. New `setLivezProbe()` injection seam lets tests provide a deterministic probe; default behaviour for production users is unchanged. + +### Infrastructure + +- 91 test files (was 90), 1007 tests (was 992). New `test/context-lessons.test.ts` (8 cases) covers lessons-auto-inject inclusion, empty-state no-op, project ranking, cross-project isolation, soft-delete skip, top-10 cap, confidence rendering, optional `context` string append. + +- Bundled the four follow-up issues filed during the v0.9.17 audit wave ([#446](https://github.com/rohitg00/agentmemory/issues/446), [#447](https://github.com/rohitg00/agentmemory/issues/447), [#448](https://github.com/rohitg00/agentmemory/issues/448), [#449](https://github.com/rohitg00/agentmemory/issues/449)) plus the cross-project lesson-injection gap surfaced in discussion [#381](https://github.com/rohitg00/agentmemory/discussions/381) into a single patch release — no behaviour changes for existing users beyond the hardening above. + +[0.9.18]: https://github.com/rohitg00/agentmemory/compare/v0.9.17...v0.9.18 + +## [0.9.17] — 2026-05-16 + +OpenAI-compatible LLM provider lands the universal-adapter shape (one provider config covers OpenAI, Azure OpenAI auto-detected by hostname, DeepSeek, SiliconFlow, vLLM, LM Studio, Ollama via `/v1`, plus any future endpoint that mirrors `POST /v1/chat/completions`). Worker registration now pins a stable `project_name` for engine telemetry so attribution reads cleanly across hosts. Comparison section on agent-memory.dev no longer wraps awkwardly after the v0.9.16 refresh. + +### Added + +- **OpenAI-compatible LLM provider** ([PR #307](https://github.com/rohitg00/agentmemory/pull/307), by @fatinghenji). Six providers behind one config: `OPENAI_API_KEY` + `OPENAI_BASE_URL` + `OPENAI_MODEL`. Closes [#185](https://github.com/rohitg00/agentmemory/issues/185), [#232](https://github.com/rohitg00/agentmemory/issues/232) (Ollama works via `OPENAI_BASE_URL=http://localhost:11434/v1`), [#312](https://github.com/rohitg00/agentmemory/issues/312), and [#240](https://github.com/rohitg00/agentmemory/pull/240) (superseded). + +- **Azure OpenAI auto-detection**. `OpenAIProvider` detects `.openai.azure.com` hostnames in the configured base URL at construction time and switches the request shape automatically: drops the `/v1` path prefix (deployment is in the URL), uses `api-key: ` header instead of `Authorization: Bearer`, appends `api-version=` query param. Default api-version is `2024-08-01-preview`; override via `OPENAI_API_VERSION`. Closes the gap with [PR #219](https://github.com/rohitg00/agentmemory/pull/219). + +- **`OPENAI_TIMEOUT_MS` env var** (PR #307). Outbound fetch timeout on the new provider, default 60s, AbortController-bounded with a clear timeout error message that points at the env var. The other raw-fetch providers (anthropic / gemini / openrouter / minimax) share the same gap tracked in [#373](https://github.com/rohitg00/agentmemory/issues/373). + +- **`OPENAI_REASONING_EFFORT` passthrough** (PR #307). Forwarded as `reasoning_effort` on the request body for OpenAI reasoning models (`o1`, `o3`, `gpt-*-reasoning`) and providers that mirror that schema (Ollama Cloud thinking models). Standard chat models reject this field with 400 — README documents the caveat. Set to `"none"` for thinking models that return reasoning but no content. The provider also falls back to `message.reasoning` when `message.content` is empty, covering the Ollama Cloud thinking-model shape. + +### Changed + +- **Worker `telemetry.project_name` pinned to `"agentmemory"`** ([PR #426](https://github.com/rohitg00/agentmemory/pull/426)). `iii-sdk`'s `InitOptions.telemetry.project_name` auto-detects when omitted — falls through cwd → package.json basename → hostname, which produces inconsistent identifiers per host (`agentmemory`, `node`, `npm`, occasionally the user's home dir basename when launched via npx). Pinning the value gives every install the same stable project identifier in the engine's metrics + traces output. Also pins `language: "node"` and `framework: "iii-sdk"` for the same reason. + +- **`OPENAI_API_KEY_FOR_LLM=false` opt-out gate** (PR #307, fix pushed via maintainer edit). `detectLlmProviderKind()` now mirrors `detectProvider()`'s existing gate — users who set `OPENAI_API_KEY` only for embeddings (via the `OPENAI_BASE_URL` + `OPENAI_EMBEDDING_MODEL` flow from #186) won't see the LLM auto-activate. The README's `.env` template now leads with an explicit shared-use callout above the LLM section pointing at the opt-out. + +- **Compare section on agent-memory.dev** ([PR #427](https://github.com/rohitg00/agentmemory/pull/427)). `AGENTMEMORY VS. THE FIELD.` title → `VS. THE FIELD.` (the eyebrow already reads VS., so the longer version was redundant + wrapped ugly). Added `text-wrap: balance` to `.section-title` globally so any wrapping title breaks at a balanced point. `NATIVE PLUGINS` cell value `6 (Claude/Codex/OpenClaw/Hermes/pi/OpenHuman)` → `6` (agent names already visible in the Agents grid two sections above). Row grid rebalanced (label 1.4fr / agentmemory 1.3fr / competitors 1fr each), added `word-break: break-word` + 24px row padding so cells like `YES (APACHE-2.0)` and `2 (Qdrant, Neo4j)` have breathing room. + +### Infrastructure + +- Provider factory at `src/providers/index.ts` now dispatches `"openai"` through `createBaseProvider`; type union extended in `src/types.ts:132`. `VALID_PROVIDERS` set in `src/config.ts` includes the new entry so the fallback-chain config accepts it. + +[0.9.17]: https://github.com/rohitg00/agentmemory/compare/v0.9.16...v0.9.17 + +## [0.9.16] — 2026-05-15 + +Two parallel waves landed back-to-back. (1) DevEx polish on top of v0.9.15's foundation: 5-port ready panel that shows REST/Viewer/Streams/Engine/iii-console in one boxed note, iii-console install probe + auto-install on first run, interactive global-install prompt that replaces the passive npx hint (so `agentmemory stop` actually works in new shells), onboarding wizard now wires every selected agent inline via the same `agentmemory connect` adapter the CLI exposes, plus a memory-share callout so users understand a single server feeds every wired agent. (2) Marketing site refresh against the v0.9.15 surface: new `AS FEATURED IN` bar (AlphaSignal · Agentic AI Foundation · Trendshift), six first-party agents in the featured grid (added pi + OpenHuman), MCP messaging reworded as opt-in surface so REST reads as the primary protocol. + +### Added + +- **5-port ready panel** ([PR #410](https://github.com/rohitg00/agentmemory/pull/410)). Replaces the single-line `Memory ready on :3111 · viewer on :3113 · try: agentmemory demo` hint with a clack `p.note` panel listing all live endpoints — REST API, Viewer, Streams, Engine, iii console — each derived from the configured env vars (`AGENTMEMORY_URL`, `III_REST_PORT`, `III_VIEWER_PORT`, `III_STREAM_PORT`, `III_ENGINE_URL`) so a remote-bind setup reads correctly, not as hardcoded localhost. + +- **iii console install probe + auto-install** (PR #410). New `ensureIiiConsole()` checks for the iii-console binary on PATH or in `~/.local/bin`; if missing, prompts interactively to run `curl -fsSL https://install.iii.dev/console/main/install.sh | sh`. Console is first-class — not optional. On install acceptance the ready panel includes the binary's resolved path and a runnable launch hint (` -p `); on decline the panel surfaces the install one-liner. + +- **Interactive global-install prompt** (PR #410). Replaces the passive `p.log.info` npx hint with a `p.confirm` on first npx run that runs `npm install -g @agentmemory/agentmemory@` inline. Suppressible via `preferences.skipGlobalInstall` so we never ask twice. Closes the v0.9.15-era footgun where users typed `agentmemory stop` in a new shell and hit `command not found`. + +- **Onboarding wires selected agents inline** ([PR #408](https://github.com/rohitg00/agentmemory/pull/408)). After the multi-select agents step in `runOnboarding`, asks `Run \`agentmemory connect \` for each selected agent now? [Y/n]` and dispatches each through the existing `runAdapter` path used by the explicit `agentmemory connect` command. Successes + failures get bucketed in a summary block (`Wired: claude-code, codex, cursor · Skipped/failed: hermes (manual install required)`). Cancellation prints the explicit per-agent commands for the user to run later. + +- **Memory-share callout** (PR #408). Between the agents multi-select and the provider single-select the wizard now prints a verbatim note: `All selected agents share the same memory at :3111. A memory saved by Claude Code is visible to Codex + Cursor instantly.` Closes the most common confusion ("does the same memory work across agents?") we were getting on the v0.9.15 install path. + +- **AS FEATURED IN bar on agent-memory.dev**. New `FeaturedIn` component between Hero and Stats. Three cards with real brand marks: AlphaSignal (github.com/Alpha-Signal avatar, links to the AlphaSignal Substack deep-dive), Agentic AI Foundation (self-hosted wordmark, inverted to white-on-dark for the brutalist palette), Trendshift (their official badge endpoint at `trendshift.io/api/badge/repositories/25123` which bakes the live rank + star count into the image). 3-column grid on desktop, 1-column stack on mobile, keyboard-accessible (`:focus-visible` outline). + +### Changed + +- **MCP messaging reworded as opt-in surface** ([PR #409](https://github.com/rohitg00/agentmemory/pull/409)). The endpoints summary line in `src/index.ts` was foregrounding MCP equally with REST — `Endpoints: 107 REST + 51 MCP tools + 6 MCP resources + 3 MCP prompts`. Users kept reading MCP as compulsory. Now: `REST API: 121 endpoints at http://localhost:/agentmemory/*` (primary), plus a second line `MCP surface (opt-in via \`npx @agentmemory/mcp\`): 51 tools · 6 resources · 3 prompts`. Each `connect` adapter additionally prints a protocol-note line above its install summary so the user sees which surface they're wiring (native hooks vs MCP vs both). + +- **CLI `--help` mcp subcommand description** (PR #409). Was `Start standalone MCP server (no engine required)`. Now: `Start standalone MCP shim — opt-in surface for MCP-only clients (Cursor, Gemini CLI, etc). REST always available at :3111.` + +- **Engine version-mismatch warning copy** (PR #410). The warning that fires when iii on PATH doesn't match `IIPINNED_VERSION` previously said `agentmemory v0.9.14+ pins v0.11.2`. Now reads `agentmemory v${VERSION} pins v${IIPINNED_VERSION}` so the string never lies post-release. + +- **CommandCenter — iii console messaging** (PR #415). `iii CONSOLE · OPTIONAL` → `iii CONSOLE · FIRST-CLASS`. Section lede now mentions both UIs (viewer + console) are first-class and installed inline by the CLI on first run. Bullet counts updated: dropped stale `33 functions` and `49 triggers` → `121 HTTP endpoints` (matches `generated-meta.json`). + +- **Compare table refreshed** (PR #415). MCP TOOLS row our column 44 → 51. New REST ENDPOINTS row (121) and NATIVE PLUGINS row (6: Claude/Codex/OpenClaw/Hermes/pi/OpenHuman). + +- **Agents grid refresh** (PR #415). Featured row expanded from 4 to 6 — added pi and OpenHuman. Codex CLI sub line updated to `NATIVE PLUGIN` (has 6 hooks now, not MCP-only). Section title `FOUR FIRST-PARTY` → `SIX FIRST-PARTY`. Lede mentions `agentmemory connect `. + +- **Hero CTA** (PR #415). `START IN 60 SECONDS` → `START IN 30 SECONDS`. Cold install + engine spawn measured 8-12s on v0.9.15 via the native binary path; 60s was the v0.9.0-era Docker-first claim. + +### Fixed + +- **CLI no longer kills its own parent process** (PR #410 follow-up via #411). `lsof -i :PORT -t` returns every PID with an active TCP connection — including the CLI's own keep-alive `fetch()` from `isEngineRunning()`. Now restricts to `-sTCP:LISTEN` and filters `process.pid` from the candidate set. The bug used to surface as exit code 137 with state files left stranded. + +- **Splash banner ASCII** ([PR #411](https://github.com/rohitg00/agentmemory/pull/411)). The hand-drawn block-art "agentmemory" wordmark in `src/cli/splash.ts` had broken middle glyphs (`_ _` instead of `_ __` around the 'n', merged 'tm/me' segments). Replaced with verified `figlet agentmemory` standard-font output. All 6 rows render at exactly 70 cols, regenerable. + +- **README install hoisted to top** (PR #411). Install section was buried at line 306 below benchmarks and comparison tables. Moved a 4-line install block right under the nav anchors at the top with the bare `agentmemory` command leading. Quick-start kept below for the in-depth walkthrough. Adds an npx-cache caveat with three workarounds (`npm install -g`, `npx -y @latest`, manual `rm -rf ~/.npm/_npx` annotated as POSIX-only). + +### Infrastructure + +- New modules: `src/cli/connect/{index,types,claude-code,codex,cursor,gemini-cli,openclaw,hermes,pi,openhuman,json-mcp-adapter}.ts`, `src/cli/doctor-diagnostics.ts`, `src/cli/remove-plan.ts`, `src/cli/splash.ts`, `src/cli/preferences.ts`, `src/cli/onboarding.ts`, `src/logger.ts` (bootLog shim) — actually shipped in v0.9.15; relisted here only to call out that v0.9.16 keeps every module backward-compatible (no signature breaks on `runOnboarding`, no field removals from `Prefs`). +- New tests: existing CLI test suites continue at 944+ passing. mcp-standalone pre-existing failures unrelated. +- Website: new `FeaturedIn` component (~150 LOC across `.tsx` + `.module.css`); `next.config.ts` adds `aaif.io`, `trendshift.io`, `raw.githubusercontent.com` to `remotePatterns`. + +[0.9.16]: https://github.com/rohitg00/agentmemory/compare/v0.9.15...v0.9.16 + +## [0.9.15] — 2026-05-15 + +DevEx overhaul. Four PRs landed simultaneously rebuilding the first-run experience to SkillKit-grade polish: splash banner + interactive agent grid + provider picker + smart-defaults preferences, `agentmemory connect ` to automate native-plugin install for 8 agents, interactive `doctor` v2 with Fix/Skip/More/Quit prompts and a `--all` auto-fix flag, `agentmemory remove` for clean uninstall with destruction-plan confirmation, plus five silent-killer fixes around viewer port collisions, engine version-mismatch detection, `stop --force` override, adopt-on-attach state recording, and an npx-to-global-install hint. + +### Added + +- **Splash banner + onboarding wizard** ([PR #403](https://github.com/rohitg00/agentmemory/pull/403)). Terminal-width-aware ASCII (full at ≥120 cols, compact at 80–119, single-line below 80), `NO_COLOR`-aware. First-run flow: multi-select 8 native-plugin agents + 8 MCP-server agents (`⟁ Claude Code`, `◫ Cursor`, `◎ Codex`, `✦ Gemini CLI`, `⬡ OpenCode`, etc), single-select LLM provider (Anthropic / OpenAI / Gemini / OpenRouter / MiniMax / BM25-only), seeds `~/.agentmemory/.env` with the chosen provider's `*_API_KEY=` line commented. Subsequent runs skip the splash. + +- **`~/.agentmemory/preferences.json`** (PR #403). Schema-versioned JSON with `{ lastAgent, lastAgents, lastProvider, skipSplash, skipNpxHint, firstRunAt }`. Atomic write via `.tmp` + rename, graceful defaults on read failure. Exports `isFirstRun()`, `readPrefs()`, `writePrefs()`, `resetPrefs()`. + +- **`agentmemory connect ` command** ([PR #402](https://github.com/rohitg00/agentmemory/pull/402)). Automates native-plugin install for `claude-code`, `codex`, `cursor`, `gemini-cli`, `openclaw` (end-to-end working) plus stubs for `hermes`, `pi`, `openhuman` (which print manual-install hints until deeper YAML/TS adapters land). Each adapter: detect → backup the agent's existing config to `~/.agentmemory/backups/-.` → merge → re-read & verify → idempotent on re-run. Supports `--dry-run`, `--force`, `--all`. Interactive picker when run without an agent argument. + +- **`agentmemory remove` command** ([PR #406](https://github.com/rohitg00/agentmemory/pull/406)). Tears down everything agentmemory installed: pidfile + state file + preferences + backups + `~/.local/bin/iii` (only when it matches the version we installed) + any agent connections. Asks separately for `.env` (holds API keys) and for the memory data dir. Requires two confirmations by default. Supports `--force`, `--keep-data`. + +- **`agentmemory doctor` v2** (PR #406). Replaces the passive reporter with an interactive fixer. Each diagnostic prints problem + cause + fix-preview, then offers Fix / Skip / More / Quit. Checks: missing `.env`, missing LLM key, engine version mismatch, viewer port unreachable, stale pidfile, empty API keys, iii on PATH outside `~/.local/bin/iii`. New flags: `--all` (apply every fix, for CI), `--dry-run` (show plan only). + +- **`agentmemory stop --force`** ([PR #405](https://github.com/rohitg00/agentmemory/pull/405)). Bypasses the Docker-compose heuristic refusal so engines started before v0.9.14 (which had no state file) can be torn down without a hand-rolled `lsof | xargs kill -9`. + +- **`--reset` flag**. Wipes preferences and re-runs the onboarding wizard (PR #403). + +- **`--verbose` / `AGENTMEMORY_VERBOSE=1`**. Restores the pre-v0.9.15 25-line `[agentmemory] X` engine-boot log stream for debugging (PR #403). Default is muted to a single ready-line. + +### Changed + +- **First-run output trimmed from 30+ lines to ~10**. The `[agentmemory] Worker v0.9.x` boot log stream is now buffered behind a `bootLog` shim (`src/logger.ts`) and only surfaces when `--verbose` is passed. The default surface is the splash banner, the onboarding prompts (first run only), a single engine spinner, and a one-line ready hint: `Memory ready on :3111 · viewer on http://localhost:3113 · try: agentmemory demo`. + +- **`isEngineRunning()` short-circuit now adopts the engine** ([PR #405](https://github.com/rohitg00/agentmemory/pull/405)). When the CLI finds an existing engine on `:3111`, it now writes `~/.agentmemory/iii.pid` (resolved via `lsof -i :PORT -sTCP:LISTEN -t`) and `~/.agentmemory/engine-state.json` (`{kind:"native", configPath, attached:true}`) if neither exists. Closes the migration gap where pre-v0.9.14 engines could not be stopped via `agentmemory stop` because no state file was written by the older code. + +- **Viewer port auto-bump** (PR #405). When `:3113` is taken, the viewer now retries 3114 → 3115 → … up to 3122 before failing loud. Pre-fix behaviour was a silent `Viewer port 3113 already in use, skipping viewer.` which left users staring at the previous run's stale viewer. + +- **Engine version-mismatch warning** (PR #405). When the iii binary on PATH (or attached engine) is not the pinned `IIPINNED_VERSION` (currently v0.11.2), the CLI now prints a clearly-labelled `p.log.warn` with the override path (`AGENTMEMORY_III_VERSION=...` env var or downgrade curl one-liner). Pre-fix was silent acceptance — v0.11.6 on PATH led to undebuggable EPIPE loops. + +- **npx PATH hint** (PR #405). After the engine is ready, runs invoked via `npx` get one extra line: `Tip: install globally for the bare \`agentmemory\` command: npm install -g @agentmemory/agentmemory`. Suppressible via `preferences.skipNpxHint`. + +### Fixed + +- `lsof -i :PORT -t` was returning client PIDs alongside the LISTEN-socket owner (already fixed in v0.9.14 via the `-sTCP:LISTEN` flag + `process.pid` filter; called out here so anyone bisecting the wave knows the LISTEN flag is what stopped the CLI from killing itself). + +### Infrastructure + +- New modules: `src/cli/splash.ts`, `src/cli/preferences.ts`, `src/cli/onboarding.ts`, `src/cli/connect/{index,types,claude-code,codex,cursor,gemini-cli,openclaw,hermes,pi,openhuman,json-mcp-adapter}.ts`, `src/cli/doctor-diagnostics.ts`, `src/cli/remove-plan.ts`, `src/logger.ts` (bootLog shim). +- New tests: `test/preferences.test.ts` (7), `test/cli-connect.test.ts` (13), `test/cli-doctor-fixes.test.ts` (18), `test/cli-remove.test.ts` (13). 944 tests passing, 10 pre-existing `mcp-standalone.test.ts` failures unrelated to this wave. + +[0.9.15]: https://github.com/rohitg00/agentmemory/compare/v0.9.14...v0.9.15 + +## [0.9.14] — 2026-05-15 + +CLI bootstrap rework so `npx @agentmemory/agentmemory` stops failing on Rancher Desktop and other Docker-shim daemons. The native iii-engine binary is now the first-class start path; Docker becomes opt-in. Also ships `agentmemory stop` so engines started in the background can be torn down without `lsof | xargs kill`. README agents grid reorders OpenHuman next to the other native-integration agents. + +### Added + +- **`agentmemory stop` command** ([PR #396](https://github.com/rohitg00/agentmemory/pull/396)). Reads `~/.agentmemory/iii.pid` first, falls back to `lsof -i :PORT -sTCP:LISTEN -t`, sends `SIGTERM`, waits 3s, escalates to `SIGKILL`. iii-engine doesn't currently handle `SIGTERM` cleanly so the SIGKILL escalation is what actually frees the port. State file (`~/.agentmemory/engine-state.json`) records whether the engine was started natively or via `docker compose up -d`, so `stop` runs `docker compose -f down` for Docker engines instead of signaling the host's Docker socket proxy by accident. + +- **`AGENTMEMORY_USE_DOCKER=1` env var**. Opt-in path for users who want the bundled `docker-compose.yml` to keep handling iii-engine lifecycle. Without it, the CLI prefers the native binary in `~/.local/bin/iii`. Documented in `npx @agentmemory/agentmemory --help`. + +### Changed + +- **`startEngine()` fallback order rewritten** ([PR #396](https://github.com/rohitg00/agentmemory/pull/396)). New tier order: (1) `iii` on PATH, (2) `~/.local/bin/iii` or other fallback paths, (3) interactive clack `p.select` with three choices — auto-install the pinned v0.11.2 binary, use Docker compose, or print manual install instructions and exit, (4) Docker compose only via explicit opt-in or the post-install failure fallback, (5) fail-loud with install instructions. CI / non-TTY environments auto-pick install. The Docker fallback was tier 2 and silently fired on every cold start; on Rancher Desktop `docker compose up -d` returns 0 even when the daemon's pull silently fails, which is what produced the "engine started but REST never responded" misdiagnoses we've been seeing this week. + +- **Installer logic extracted from `runUpgrade` into `runIiiInstaller()`**. Both first-run and `agentmemory upgrade` now share the same pinned-v0.11.2 curl-and-tar path. The pre-v0.9.14 installer only ran on `npx @agentmemory/agentmemory upgrade`, so first-time users never auto-installed. + +- **`installInstructions()` copy reordered**. The curl one-liner now leads (path A), Docker is path B with the `AGENTMEMORY_USE_DOCKER=1` env-var hint. The historical "Why pinned" rationale moved out of the failure note (it's still in the source comment for anyone debugging the pin choice). + +- **README agents grid reordered** ([PR #397](https://github.com/rohitg00/agentmemory/pull/397)). Row 1 is now Claude Code → Codex CLI → OpenClaw → Hermes → pi → OpenHuman → Cursor → Gemini CLI. OpenHuman moved from slot 3 to slot 6 so the native-Memory-trait callout reads cleanly alongside the other native-integration agents. + +### Fixed + +- **CLI no longer kills its own parent process**. `lsof -i :PORT -t` returns every PID with an active TCP connection to the port, including the CLI's own keep-alive `fetch()` from `isEngineRunning()`. Without a LISTEN filter, `agentmemory stop` would SIGKILL itself — exit code 137, state files never cleaned up. Now filters to `-sTCP:LISTEN` and drops `process.pid` from the candidate set. + +- **`agentmemory stop` no longer clears the pidfile when a stale process holds the port**. The HTTP probe can fail while the engine is hung, paused, or in a half-closed state. The pre-fix behaviour cleared the pidfile and printed "Nothing to stop" — the next run would silently start a second engine on a port that the first engine still owns. Now preserves the pidfile and surfaces the live PIDs with `ps -p` + `lsof` hints so the operator can investigate before manual cleanup. + +- **Docker-started engines stop safely**. The pre-fix code called `findEnginePidsByPort` and signaled whatever held :3111. When the engine was started via `docker compose up -d`, that's the host's Docker socket proxy (`com.docker.backend`, `vmnetd`), not the engine — killing it took down Docker Desktop networking for every other container. The new state file lets `runStop` detect Docker-managed engines and run `docker compose down` instead. + +### Infrastructure + +- Engine process now writes both `~/.agentmemory/iii.pid` and `~/.agentmemory/engine-state.json` at spawn. State file is cleared on clean shutdown or abnormal exit; pidfile is preserved when stale PIDs hold the port so the operator can investigate. + +[0.9.14]: https://github.com/rohitg00/agentmemory/compare/v0.9.13...v0.9.14 + +## [0.9.13] — 2026-05-15 + +Six PRs landed since v0.9.12 — `.env.example` discovery shipped (#372), CJK BM25 tokenizer landed (#344 / PR #362), `benchmark/load-100k.ts` load harness landed (#346 / PR #363), one-click deploy templates for fly.io / Railway / Render / Coolify added (#343 / PR #361), Gemini provider defaults moved to current GA models (#246 + #368 / PR #370), and the in-tree Python ecosystem story switched from a duplicate REST client to a one-page `iii-sdk` example (#342 / PR #364). Plus 14 Dependabot security advisories closed via Next.js + PostCSS bumps. + +### Added + +- **`.env.example` at repo root + bundled in the npm tarball** ([#372](https://github.com/rohitg00/agentmemory/issues/372), closes [#47](https://github.com/rohitg00/agentmemory/issues/47), [#293](https://github.com/rohitg00/agentmemory/issues/293), partial [#233](https://github.com/rohitg00/agentmemory/issues/233)). Every env var actually read by `src/` is now documented in one place, grouped by surface (LLM provider, embedding provider, auth, search tuning, behaviour flags, CLI runtime, ports, iii engine pin, Claude Code bridge, Obsidian export). Every line is commented out by default so the file ships as a config template, not a config. The npm package now lists `.env.example` in its `files` field so `npm i -g @agentmemory/agentmemory` carries it. + +- **`agentmemory init` command**. Copies the bundled `.env.example` to `~/.agentmemory/.env` if that file doesn't already exist; refuses to overwrite an existing config and prints a diff command pointing at the latest template. Wired into the CLI help block alongside `status` / `doctor` / `demo` / `upgrade` / `mcp` / `import-jsonl`. + +- **CI sync-checker for `.env.example`** (`scripts/check-env-example.mjs`). Walks every `.ts` / `.mts` / `.mjs` / `.js` file under `src/`, extracts `process.env["KEY"]` / `env["KEY"]` / `getMergedEnv()["KEY"]` / `getEnvVar("KEY")` references, and fails CI when `src/` reads an env var the template doesn't document (or vice versa). Plugged into `.github/workflows/ci.yml` after `npm test`. Initial bootstrap: 60 keys in sync. + +- **CJK tokenizer for BM25 search** ([#344](https://github.com/rohitg00/agentmemory/issues/344), PR [#362](https://github.com/rohitg00/agentmemory/pull/362)). New `src/state/cjk-segmenter.ts` detects CJK input by Unicode block and routes to `@node-rs/jieba` (Chinese, native, no model download), `tiny-segmenter` (Japanese, pure JS, ~25 KB), or rule-based syllable-block split (Korean). Both segmenters declared in `optionalDependencies` so the base install stays lean; soft-fail with a one-time stderr hint when the dep is missing. Order-preserving single-pass tokenization across mixed CJK + non-CJK runs (regression test for `"abc 메모리 def 项目 ghi"` returns `["abc","메모리","def","项目","ghi"]`). + +- **`benchmark/load-100k.ts` load harness** ([#346](https://github.com/rohitg00/agentmemory/issues/346), PR [#363](https://github.com/rohitg00/agentmemory/pull/363)). Hand-rolled, dependency-free harness that seeds N synthetic memories against a local daemon at `http://localhost:3111` and records p50 / p90 / p99 latency + throughput for `POST /agentmemory/remember`, `POST /agentmemory/smart-search`, and `GET /agentmemory/memories?latest=true` across the matrix N ∈ {1k, 10k, 100k} × concurrency C ∈ {1, 10, 100}. Content drawn from a seedable `mulberry32` PRNG so re-running against the same build produces the same seed corpus. Results land in `benchmark/results/load-100k-.json`. Wired as `npm run bench:load`. + +- **One-click deploy templates** for fly.io, Railway, Render, and Coolify ([#343](https://github.com/rohitg00/agentmemory/issues/343), PR [#361](https://github.com/rohitg00/agentmemory/pull/361)). Each template under `deploy//` ships a multi-stage Dockerfile that `COPY --from=iiidev/iii:0.11.2`s the engine binary into a `node:22-slim` runtime, npm-installs `@agentmemory/agentmemory` under `/opt/agentmemory` with `iii-sdk` pinned via `package.json` overrides (avoids the caret-resolves-to-0.11.6 drift), and runs an entrypoint that rewrites the bundled `iii-config.yaml` to bind `0.0.0.0` + use absolute `/data` paths, chowns the platform-mounted volume to `node:node` via `gosu`, generates a first-boot HMAC secret, and exec's the agentmemory CLI as the unprivileged `node` user under `tini` (with `TINI_SUBREAPER=1`). Verified end-to-end on fly.io (machine in `iad`, 1 GB volume, healthcheck passing). + +- **`examples/python/`** quickstart + observation/recall flow showing `iii-sdk` (Python) calling `mem::remember` / `mem::smart-search` / `mem::context` directly over `ws://localhost:49134` ([#342](https://github.com/rohitg00/agentmemory/issues/342), PR [#364](https://github.com/rohitg00/agentmemory/pull/364)). Replaces a duplicate-transport Python REST client (initial PR #360, closed) with a single-SDK story — the same `iii-sdk` install (`pip install iii-sdk` / `cargo add iii-sdk` / `npm install iii-sdk`) talks to every agentmemory function from any language. + +### Changed + +- **Gemini provider defaults bumped to current GA models** (PR [#370](https://github.com/rohitg00/agentmemory/pull/370), closes [#368](https://github.com/rohitg00/agentmemory/pull/368), [#246](https://github.com/rohitg00/agentmemory/pull/246)). LLM default `gemini-2.0-flash` → `gemini-2.5-flash` (the moving `gemini-flash-latest` alias was rejected — release behaviour should be deterministic). Embedding default `text-embedding-004` → `gemini-embedding-001` (the previous default is deprecated and shuts down 2026-01-14 per `ai.google.dev/gemini-api/docs/deprecations`). Three implementation details ride along: (1) URL path `:batchEmbedContent` → `:batchEmbedContents`, (2) every request now sends `outputDimensionality: 768` so the returned vectors match `GeminiEmbeddingProvider.dimensions = 768` and the index-restore dim guard from #248 — no reindex needed, (3) returned vectors are L2-normalized before the result-array push because `gemini-embedding-001` does **not** normalize by default unlike `text-embedding-004` and without this the downstream cosine-similarity math silently collapses recall. `l2Normalize` warns once on a zero-norm embedding so operators can correlate index quality dips with upstream regressions. + +### Security + +- **14 open Dependabot advisories closed via Next.js + PostCSS bumps** (PR [#348](https://github.com/rohitg00/agentmemory/pull/348)). Closed: 13 Next.js advisories (middleware/proxy bypass + SSRF on WebSocket upgrades + DoS via connection exhaustion + CSP-nonce XSS + image-opt DoS + RSC cache poisoning + beforeInteractive XSS + segment-prefetch routes) by bumping the website's Next.js to `^16.2.6`. Plus the PostCSS XSS-via-unescaped-`` advisory closed by pinning to `^8.5.10` via `overrides` in `website/package.json`. Verified `npm audit --omit=dev` returns 0 and `npm run build` clean on Next 16.2.6. Dependabot now runs weekly against six update streams (npm × 5 paths + github-actions) per the new `.github/dependabot.yml`. + +### Contributors + +External contributors landed this release: + +- [@fatinghenji](https://github.com/fatinghenji) — pre-cleanup work on the OpenAI-compatible LLM provider (PR #240 / PR #307); the universal-adapter shape will land in the next minor once branch maintenance catches up. +- [@AmmarSaleh50](https://github.com/AmmarSaleh50) — Gemini embedding migration with L2-norm + 768-dim plumbing (PR #246, folded into #370). +- [@yut304](https://github.com/yut304) — Gemini LLM default deprecation fix (PR #368, folded into #370). + +Thanks also to the issue reporters whose precise repros drove the search-quality + viewer + config-template work this cycle. + +## [0.9.12] — 2026-05-13 + +Four landed PRs since v0.9.11 — one type-correctness fix, one search-quality fix (BM25 unicode + vector-index live-write), one viewer hardening (CSP-clean fonts + load-error surface), and one integrations security hardening (bearer token over plaintext HTTP). + +### Fixed + +- **BM25 tokenizer now indexes non-ASCII (Greek, accented Latin, Hebrew, Arabic) and the vector index is actually populated at runtime** ([#327](https://github.com/rohitg00/agentmemory/pull/327), closes [#295](https://github.com/rohitg00/agentmemory/issues/295)). `src/state/search-index.ts` previously stripped every non-ASCII character via `\w` (JS `\w` is ASCII-only), so non-English search returned empty results across the board. Regex replaced with `/[^\p{L}\p{N}\s/.\\-_]/gu` — keeps Unicode letters/numbers, preserves underscore (matters for `memory_recall`-style identifiers), keeps existing path/separator handling. Separately, `VectorIndex.add()` had **zero callers** anywhere in `src/` — the vector index was loaded from disk at startup and never updated at runtime, so hybrid search returned stale results forever. New `vectorIndexAddGuarded()` helper in `src/functions/search.ts` wires `vectorIndex.add()` into `remember.ts`, `observe.ts` synthetic-compression branch, `compress.ts` post-LLM compression, and the cold-start `rebuildIndex()` walk — with a per-write dimension guard (symmetric to the persistence-load guard from #248), 16k-char input clipping (so an oversized memory can't 400 the embed call), and consistent stderr logging that no longer swallows embed failures. `migrateVectorIndex()` utility re-embeds every memory + per-session observation against a new provider when dimensions change; per-session try/catch isolation so one bad session can't abort the whole migration; structured `failedSessions[]` result with a `""` sentinel that distinguishes a catastrophic list-failure from per-session failures. Soft-fail throughout: a downed embedder never breaks an upstream save. Live-tested end-to-end against Greek fixtures (thanks @nik1t7n for the original repro on Cyrillic content; test fixtures swapped to Greek before merge). + + **CJK caveat preserved**: this fix recovers Greek, Cyrillic, accented Latin, Hebrew, Arabic, and other space-delimited scripts. CJK languages without spaces between characters still tokenize as a single sentence-long token — separate concern, needs a segmenter (PR #224 area). + +- **`@agentmemory/mcp` standalone shim's `RetentionScore` type no longer declares `source` twice** ([#326](https://github.com/rohitg00/agentmemory/pull/326), closes [#277](https://github.com/rohitg00/agentmemory/issues/277)). `src/types.ts:835` and `:842` both declared `source?: "episodic" | "semantic"` on `RetentionScore`. TypeScript silently accepts duplicate property declarations when the types are identical, so the build never errored — but the documented JSDoc on the first declaration (the #124 back-compat note explaining `undefined` should probe both scopes) was effectively shadowed by the duplicate. Removed the second declaration; grep confirmed no caller writes `source` twice on the same `RetentionScore` row, so the cleanup is a pure type-correctness fix with no runtime change. Thanks @cl0ckt0wer for the precise file:line trace. + +- **Viewer dashboard no longer sticks on "Loading dashboard…" when the page loads** ([#335](https://github.com/rohitg00/agentmemory/pull/335), closes [#323](https://github.com/rohitg00/agentmemory/issues/323)). Two narrow fixes from @hem57's Windows repro: (1) removed the `` to `fonts.googleapis.com` — the viewer's strict CSP (`default-src 'none'`, `style-src 'unsafe-inline'`, `font-src 'self'`) blocks external stylesheet origins by design, so every page load logged a CSP violation for the font CSS and another for each blocked font file; system-font fallbacks were already declared on every `--font-*` CSS variable so dropping the external `` is a clean swap. (2) Wrapped `loadDashboard()` body in a `try/catch` that renders the error inline ("Dashboard failed to load: ") instead of leaving the placeholder text up forever — future "stuck Loading" reports now come with concrete error messages instead of just a screenshot of the placeholder. + +- **Integrations (hermes / openclaw / pi) now warn when sending the bearer token over plaintext HTTP to a non-loopback host** ([#315](https://github.com/rohitg00/agentmemory/pull/315), closes [#275](https://github.com/rohitg00/agentmemory/issues/275)). Symmetric guard across all three plugin runtimes (Python / mjs / TypeScript): a request that would attach `Authorization: Bearer ` to a `http://:port` URL now logs a one-time stderr warning telling the operator the token is observable on the wire. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) and `https://` URLs are exempt. New env knob `AGENTMEMORY_REQUIRE_HTTPS=1` escalates the warning to a hard refusal — the plugin throws before any request is sent, so a misconfigured deployment can fail loudly instead of leaking the bearer once. Edge cases verified by 13 tests: IPv6 `[::1]` loopback, RFC1918 LAN IPs (`192.168.x.x` / `10.x.x.x` warn — NOT loopback), lookalike hostnames (`localhost.evil.com` warns), no-secret short-circuit (guard never fires when no bearer would be sent), https-with-secret silent. Thanks [@mvanhorn](https://github.com/mvanhorn) for the cross-runtime implementation. + +### Changed + +- `@agentmemory/mcp` package version bumped from 0.9.11 → 0.9.12 to lockstep with the main package. + +## [0.9.11] — 2026-05-13 + +Three additions on top of v0.9.10: OpenAI Codex got a plugin platform and agentmemory now ships a Codex manifest + marketplace alongside the existing Claude Code one (so `codex plugin marketplace add rohitg00/agentmemory` installs MCP + 6 hooks + 4 skills in one step); the OpenClaw integration now actually claims the `plugins.slots.memory` slot via `registerMemoryCapability` (older builds advertised the slot via manifest `kind` but never declared the capability so the slot reported `unavailable`); and the marketing website's hero CTA row now ships a live "Star on GitHub" button. + +### Added + +- **Codex plugin support** ([#311](https://github.com/rohitg00/agentmemory/pull/311)). `plugin/.codex-plugin/plugin.json` Codex manifest pointing at the same shared `.mcp.json` + `skills/` directory the Claude Code manifest uses, plus a Codex-shaped `plugin/hooks/hooks.codex.json` that registers exactly the events Codex's hook engine supports (`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop`) and drops the Claude-Code-only ones (`SubagentStart`, `SubagentStop`, `SessionEnd`, `Notification`, `TaskCompleted`, `PostToolUseFailure`). Verified against `codex-rs/hooks/src/engine/discovery.rs` that Codex injects `CLAUDE_PLUGIN_ROOT` into hook subprocesses for OOTB compat with existing Claude Code plugin scripts — so the same `${CLAUDE_PLUGIN_ROOT}/scripts/...` references work on both hosts. `.codex-plugin/marketplace.json` at the repo root publishes the plugin via the Codex marketplace API (`git-subdir` source pointing at `./plugin`), so end users run `codex plugin marketplace add rohitg00/agentmemory && codex plugin install agentmemory` once and get MCP + lifecycle hooks + 4 skills (`/recall`, `/remember`, `/session-history`, `/forget`). + +- **`Star on GitHub` button in the website hero CTA row** ([#316](https://github.com/rohitg00/agentmemory/pull/316)). Adds a ghost-style `GitHubStarButton` component next to `START IN 60 SECONDS` and `SEE IT MOVE` with inline SVG star icon, `STAR` label, and a live stargazer count fetched once on mount from `api.github.com/repos/rohitg00/agentmemory`. Count is cached in `localStorage` for 30 minutes per repo. Graceful degradation: if the API is unreachable / rate-limited / blocked, the button still renders without the count. No new dependencies. + +### Fixed + +- **OpenClaw `plugins.slots.memory = "agentmemory"` now reports as available, not `unavailable`** ([#310](https://github.com/rohitg00/agentmemory/pull/310)). On OpenClaw `2026.4.9+`, the integration declared `"kind": "memory"` in its manifest and wired `before_agent_start` / `agent_end` hooks but **never called `api.registerMemoryCapability(...)`** — so OpenClaw's memory-slot machinery saw no claim on the slot even though the hooks and REST API worked end-to-end. The fix lands `api.registerMemoryCapability({ promptBuilder })` at register time when the host exposes it, with the `promptBuilder` accepting the documented `{ availableTools, citationsMode? }` params and emitting a three-line block identifying agentmemory as the active provider. Older OpenClaw builds without the capability API still load via the existing hook-only path (`typeof api.registerMemoryCapability === "function"` guard). The PR does **not** ship a `MemoryPluginRuntime` adapter because OpenClaw's current `MemoryRuntimeBackendConfig` type is exactly `{ backend: "builtin" } | { backend: "qmd"; ... }` — both in-process backends that don't fit an external REST shape. Verified against `openclaw@2026.5.7`'s `plugin-sdk/src/plugins/types.d.ts` and `memory-state.d.ts` declarations directly. (closes the bug premise originally surfaced in the closed PR #302, which had attempted the same goal but with an import path that wasn't in the package's `exports` map and a symbol that didn't exist in any released openclaw version.) + +### Changed + +- `@agentmemory/mcp` package version bumped from 0.9.10 → 0.9.11 to lockstep with the main package. +- README now distinguishes **Codex CLI (MCP only)** from **Codex CLI (full plugin)** in the per-host install table and lists the marketplace install command for the latter. + +## [0.9.10] — 2026-05-12 + +Three deployment-shape fixes reported live by [@flamerged](https://github.com/flamerged) ([#299](https://github.com/rohitg00/agentmemory/issues/299), [#301](https://github.com/rohitg00/agentmemory/issues/301)): the v0.9.7 docker-compose persistence fix was incomplete because the distroless engine runs as UID 65532 but `docker volume create` initializes the named volume mountpoint as `root:root mode 755`; the viewer's port-detection JS hardcoded `'3113'` so any reverse-proxy fronting on port 80/443 returned an empty dashboard; and the `mem::context` budget loop short-circuited the entire selection on the first oversized block — pinning a large slot could starve all other context blocks even when smaller ones would have fit. + +### Fixed + +- **`docker-compose.yml` now chowns the named volume to UID 65532 before the engine starts.** The `iiidev/iii` image is distroless and runs as UID 65532; docker initializes named volumes as `root:root mode 755`; the engine has no `sh` / `chown` to self-heal at startup, so writes to `/data/state_store.db` and `/data/stream_store` returned `Permission denied (os error 13)`. The engine silently buffered in RAM, the API kept reporting `success: true`, and state evaporated on every container restart — the exact symptom v0.9.7's working-directory fix set out to solve. The compose file now ships an `iii-init` one-shot service (`busybox:1.36`, ~4 MB, exits in <100 ms) that runs `chown -R 65532:65532 /data && chmod 755 /data` once at compose-up, plus a `user: "65532:65532"` directive on the `iii-engine` service and a `depends_on.iii-init.condition: service_completed_successfully` gate so the engine never starts before the volume is owner-correct. Existing deployments that already hit the bug should run `docker compose down && docker compose up -d` after upgrading — the init container will fix the volume in place on the first run. (#301, closes [#301](https://github.com/rohitg00/agentmemory/issues/301) — thanks [@flamerged](https://github.com/flamerged) for the precise UID + mountpoint trace and the chown workaround that confirmed the fix shape) + +- **Viewer dashboard now works behind any reverse proxy on standard ports (80 / 443).** `src/viewer/index.html`'s port-detection JS resolved the REST base URL through `params.get('port') || window.location.port || '3113'` — when the page was served on port 80 or 443, `window.location.port` was the empty string and the fallback hardcoded `':3113'`, so every browser-side `/agentmemory/*` fetch missed the proxied origin and went to `:3113` (typically loopback-only on these deployments, so unreachable from outside). The viewer rendered cleanly but every panel showed the empty "first run" state — even though `curl /agentmemory/sessions` (no explicit port) returned correct data. The fix uses `window.location.origin` as the REST base when neither `?port=` nor `window.location.port` is set, and constructs the WebSocket URL from `window.location.host` (with whatever port the page was served on) so the same-origin path works for both REST and live updates. Behaviour with an explicit `?port=N` or non-default `window.location.port` is unchanged. (#299, closes [#299](https://github.com/rohitg00/agentmemory/issues/299) — thanks [@flamerged](https://github.com/flamerged) for the deployment context + the lines-927–930 trace) + +- **`mem::context` budget loop no longer bails the entire selection on the first oversized block.** The selection loop in `src/functions/context.ts` used `break` when `usedTokens + block.tokens > budget`, so a single oversized block at the top of the sorted list — most commonly a fat pinned slot under #288's new injection path — cut off every smaller block that would have fit. Switched to `continue`, so smaller blocks downstream of an oversized one can still slip into the remaining budget. Net effect: pinned-slot priority semantic is preserved (pinned blocks still sort first via `recency: Date.now()`), but the worst case no longer starves the entire context. Total tokens still bounded by `tokenBudget` (default 2000); only the composition under contention changes. + +### Changed + +- `@agentmemory/mcp` package version bumped from 0.9.9 → 0.9.10 to lockstep with the main package. + +## [0.9.9] — 2026-05-11 + +Two field-reported regressions closed: pinned memory slots never reached SessionStart context (the `renderPinnedContext` and `listPinnedSlots` helpers shipped in v0.7 had no callers), and the MiniMax compression provider read its base URL straight off `process.env`, missing `~/.agentmemory/.env` values that the rest of agentmemory loads through the shared merged-env path. + +### Fixed + +- **Pinned memory slots are now actually injected into SessionStart context.** [@wyh0626](https://github.com/wyh0626) traced ([#286](https://github.com/rohitg00/agentmemory/issues/286)) that `renderPinnedContext` (`src/functions/slots.ts:182`) and `listPinnedSlots` (`src/functions/slots.ts:169`) — introduced in [#182](https://github.com/rohitg00/agentmemory/pull/182) — had zero callers in `src/`. `mem::context` (the function `/agentmemory/session/start` invokes and that `session-start.mjs` reads back into Claude Code) read profile / sessions / summaries / observations but never slots, so anything an agent wrote into a pinned slot via `mem::slot-replace` / `mem::slot-append` / `mem::slot-reflect` sat in KV and never reached the next session. The `mem::slot-reflect` writer fires on the Stop hook when `AGENTMEMORY_REFLECT=true` and persists into `pending_items` / `session_patterns` / `project_context` — the reflect → next-session loop was open. The fix wires `listPinnedSlots` → `renderPinnedContext` into `mem::context` behind `isSlotsEnabled()`, matching the existing `isGraphExtractionEnabled()` gate convention. Pinned slot content lands as a `type: "memory"` block with `recency: Date.now()` so it sorts to the top of the budget-bounded selection. `AGENTMEMORY_SLOTS=false` (the default) keeps the existing behaviour untouched. (#288, closes [#286](https://github.com/rohitg00/agentmemory/issues/286) — thanks [@wyh0626](https://github.com/wyh0626) for the precise zero-callers trace and the six-case regression suite covering global / multi-sort / unpinned-skip / empty-skip / project-shadows-global / gate-off) + +- **MiniMax provider now honors `MINIMAX_BASE_URL` from `~/.agentmemory/.env`, with the default pointed at the current Anthropic-compatible host.** [@rager306](https://github.com/rager306) reported ([#285](https://github.com/rohitg00/agentmemory/issues/285)) v0.9.8 MiniMax compression failing `MiniMax API error 401: invalid api key` against a verified-good key. Direct `curl https://api.minimax.io/anthropic/v1/messages` with the same key succeeded; the control call against `https://api.minimaxi.com/anthropic` returned 401. The 401 was the *stale fallback host* answering — the key was fine. Root cause: split-brain env source. `src/config.ts` (provider detection + API-key load) reads `~/.agentmemory/.env` through the merged `getMergedEnv()` loader; `src/providers/minimax.ts` read `MINIMAX_BASE_URL` straight off `process.env`. Deployments that kept MiniMax config in `~/.agentmemory/.env` only (systemd / launchd / `--env-file` not exported into the worker shell) got the key loaded but the base URL fell through to the stale default. The provider now resolves `MINIMAX_BASE_URL` via `getEnvVar()` (same merged loader the rest of agentmemory uses), and the default is bumped from `api.minimaxi.com` to `api.minimax.io/anthropic` per [MiniMax's current Anthropic-compatible docs](https://platform.minimax.io/docs/api-reference/text-anthropic-api). (#289, closes [#285](https://github.com/rohitg00/agentmemory/issues/285) — thanks [@rager306](https://github.com/rager306) for the precise repro and the local-patch verification) + +### Changed + +- `@agentmemory/mcp` package version bumped from 0.9.8 → 0.9.9 to lockstep with the main package. + +## [0.9.8] — 2026-05-11 + +Single-line follow-up to v0.9.7's MCP shim work. v0.9.7 surfaced probe failures, added an `AGENTMEMORY_FORCE_PROXY=1` escape hatch, and shipped `AGENTMEMORY_DEBUG=1` — but the *local-mode* `tools/list` branch was still returning only 4 tools when an agentmemory server was unreachable, not the documented 7-tool `IMPLEMENTED_TOOLS` set the shim's `InMemoryKV` actually backs. v0.9.8 fixes that. + +### Fixed + +- **`@agentmemory/mcp` local-mode `tools/list` now exposes all 7 `IMPLEMENTED_TOOLS`, not the 4-tool intersection with `ESSENTIAL_TOOLS`.** The local-fallback branch in `src/mcp/standalone.ts` filtered `getVisibleTools()` through `IMPLEMENTED_TOOLS`, but `getVisibleTools()` honors the shim process's own `AGENTMEMORY_TOOLS` env var (unset → "core" → 8 `ESSENTIAL_TOOLS`). The intersection of `ESSENTIAL_TOOLS` (8) and `IMPLEMENTED_TOOLS` (7) is exactly 4 tools: `memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`. Those four were the only ones MCP clients saw when no agentmemory server was reachable — even though the shim's `InMemoryKV` implements all seven (the four above plus `memory_export`, `memory_audit`, `memory_governance_delete`). The filter source was wrong: the shim dispatches by `IMPLEMENTED_TOOLS`, not by `ESSENTIAL_TOOLS`, so a server-tier env var should never have shaped the shim's local-fallback list. Switched to `getAllTools().filter((t) => IMPLEMENTED_TOOLS.has(t.name))`, which picks the same 7 names from the unfiltered universe regardless of `AGENTMEMORY_TOOLS`. Refactored the `tools/list` handler into an exported `handleToolsList()` for direct testability, and added a regression test asserting the local-fallback path returns exactly the 7 names under both unset and `core` modes. Verified live via stdio against a downed server: shim now returns 7 names (was 4). (#283, closes [#234](https://github.com/rohitg00/agentmemory/issues/234)) + +### Changed + +- `@agentmemory/mcp` package version bumped from 0.9.7 → 0.9.8 to lockstep with the main package. + +## [0.9.7] — 2026-05-11 + +Four follow-up fixes to v0.9.6 reported live by [@jcalfee](https://github.com/jcalfee) ([#234](https://github.com/rohitg00/agentmemory/issues/234)) and [@satabd](https://github.com/satabd) ([#278](https://github.com/rohitg00/agentmemory/issues/278)): the `@agentmemory/mcp` shim gained probe diagnostics and an escape hatch for clients that can reach the server via a non-default route, the Docker compose stack now persists state across `docker compose down` (the volume binding was unused due to a working-directory mismatch in the engine config), a cosmetic `which iii` stderr leak was suppressed, and the engine container's log output is now bounded so a crash/restart spam loop can no longer fill the host disk. + +### Fixed + +- **`@agentmemory/mcp` standalone shim now surfaces probe failures and ships an escape hatch and a debug-trace knob.** The `livez` probe in `src/mcp/rest-proxy.ts` used a 500 ms timeout and silently swallowed every failure: when the probe failed, the shim fell back to the 7-tool `IMPLEMENTED_TOOLS` set with no log line explaining why. The probe now writes the URL, HTTP status (or thrown reason), and the active timeout to `stderr` on every failure; the default timeout is raised to 2000 ms; `AGENTMEMORY_PROBE_TIMEOUT_MS` overrides it for slow loopbacks; `AGENTMEMORY_FORCE_PROXY=1` skips the probe entirely and trusts `AGENTMEMORY_URL` outright (the right escape hatch when the shim can reach the server via a known route but not the host's `localhost`); and `AGENTMEMORY_DEBUG=1` traces `tools/list` end-to-end (`handle.mode`, response shape, returned tool count, local-fallback contents) so MCP integrations have first-class visibility into what the shim is actually returning over the wire. The shim verified end-to-end via stdio against an `AGENTMEMORY_TOOLS=all` server: `tools/list` returns all 51 tools. (closes [#234](https://github.com/rohitg00/agentmemory/issues/234), thanks [@jcalfee](https://github.com/jcalfee) for the host-vs-sandboxed-client repro and detailed log captures) + +- **Docker compose stack no longer loses state on `docker compose down`.** `iii-config.docker.yaml` configured `iii-state` and `iii-stream` with relative `file_path: ./data/...`, which the engine resolves against its container `WORKDIR=/home/nonroot` — not the `/data` mount where the named `iii-data` volume lives. State and stream stores were written to the ephemeral container layer and discarded on every container restart, so memories, BM25 index, and stream backlog vanished. Both paths are now absolute (`/data/state_store.db` and `/data/stream_store`), routing writes through the named volume as the compose file always intended. Existing users need a one-time `docker compose down -v` to clear the old empty volume layout before the upgrade. + +- **CLI banner no longer leaks `which: no iii in $PATH` when iii isn't installed.** `whichBinary()` in `src/cli.ts` called `execFileSync("which", ["iii"])` with default stdio, which inherits the child's `stderr` to the parent process — and GNU `which` writes "no iii in (...)" to `stderr` (with exit 1) on miss. The catch swallowed the throw but the stderr line had already drained into the user's terminal between the `agentmemory` banner and the "iii-engine ready" line. `stdio: ["ignore", "pipe", "pipe"]` now captures both streams. Pure cosmetic, no behavior change. + +- **Docker compose stack now caps engine container log size at 30 MB total.** [@satabd](https://github.com/satabd) reported the `iiidev/iii:0.11.2` engine container filling a host disk with a 129 GB `-json.log` when the engine fell into a crash/restart spam loop ([#278](https://github.com/rohitg00/agentmemory/issues/278)). The compose service now sets `logging.driver: json-file` with `max-size: 10m` and `max-file: 3`, so unbounded engine stdout/stderr can no longer eat the host's disk. The upstream engine spam itself is filed against `iiidev/iii` — this is the compose-side guardrail. + +### Changed + +- `@agentmemory/mcp` package version bumped from 0.9.6 → 0.9.7 to lockstep with the main package. + +## [0.9.6] — 2026-05-10 + +Three reliability fixes that close field-reported regressions in v0.9.5: search/recall returns saved memories again, the standalone MCP shim no longer caps non-Claude clients at a 7-tool subset, and the Claude Code session/subagent hooks no longer block agent startup for up to five seconds against a slow or unreachable REST server. + +### Fixed + +- **`memory_smart_search` and `memory_recall` surface memories saved via `memory_save` again.** v0.9.5 indexed memories into BM25 (#258), so search ranked them correctly — but the result-enrichment step on both retrieval paths still queried `KV.observations(sessionId, obsId)`. Memories live in `KV.memories` under a synthetic sessionId, so every hit was silently dropped and clients saw `results: []`. Both `HybridSearch.enrichResults` (which powers `/smart-search`) and `mem::search`'s observation map (which powers `/search` / `memory_recall`) now fall back to `KV.memories` when the observation lookup misses, coercing the `Memory` record into a `CompressedObservation` via a new shared `memoryToObservation` helper in `src/state/memory-utils.ts`. Verified live end-to-end against an iii-engine 0.11.2 stack: pre-fix both endpoints returned `[]` for a saved memory; post-fix the memory surfaces with `score > 0` on both. (#269, closes [#265](https://github.com/rohitg00/agentmemory/issues/265)) + +- **`@agentmemory/mcp` standalone shim now exposes the server's full tool surface to non-Claude clients.** With `AGENTMEMORY_TOOLS=all` on the server, OpenCode / Cursor / Gemini CLI / Cline users expected 51 tools; the shim filtered the response through a hardcoded 7-tool `IMPLEMENTED_TOOLS` set baked in for the local InMemoryKV fallback, so they got 4 (default) or 7 (with the env var). The shim now delegates `tools/list` to `GET /agentmemory/mcp/tools` when an agentmemory server is reachable, and forwards any non-essential tool to `POST /agentmemory/mcp/call` for server-side validation. The local InMemoryKV fallback is unchanged — it still implements only the 7 essential tools, with a clearer error pointing to `AGENTMEMORY_URL` when no server is reachable. Verified live end-to-end via stdio JSON-RPC driver: pre-fix `tools/list` returned 4 tools and `memory_lesson_save` raised "Unknown tool"; post-fix `tools/list` returned 51 and `memory_lesson_save` created a lesson. (#270, closes [#234](https://github.com/rohitg00/agentmemory/issues/234)) + +- **Claude Code session/subagent hooks no longer block agent startup waiting for a slow agentmemory.** `session-start.ts` awaited a 5000 ms POST and discarded the response whenever `AGENTMEMORY_INJECT_CONTEXT=false` (the default since 0.8.10) — pure latency. `subagent-start.ts` had a `// fire and forget` comment but the code awaited a 2000 ms POST. Under fan-out (Slack-bot orchestrators, multi-agent harnesses, fanned `claude -p` jobs) the awaited timeouts stack and feed back into the engine; the reporter hit a positive feedback loop that OOM-killed iii-engine. `session-start` now fire-and-forgets on the telemetry path and caps the inject path at 1500 ms (down from 5000 ms). `subagent-start` actually fire-and-forgets, capped at 800 ms. Verified live against a black-hole TCP listener (accepts, never replies): session-start (no inject) 5.05 s → 0.85 s, session-start (inject=true) 5.05 s → 1.55 s, subagent-start 2.05 s → 0.87 s. (#271, closes [#221](https://github.com/rohitg00/agentmemory/issues/221)) + +### Changed + +- `@agentmemory/mcp` package version bumped from 0.9.4 → 0.9.6 to lockstep with the main package, fixing a release-flow miss in v0.9.5. + +## [0.9.5] — 2026-05-09 + +Bug-fix patch focused on **search recall correctness** and **plugin compatibility**. Pins `iii-engine` to v0.11.2 because v0.11.6 introduces a new sandbox-everything-via-`iii worker add` model that agentmemory hasn't been refactored for yet — pin lifts once that refactor lands. Adds a hard guard against silent vector-index corruption, fixes BM25 indexing for memories saved via `memory_save`, and lands four Hermes plugin fixes that make the memory provider actually usable end-to-end. + +If you've been seeing `memory_smart_search` return empty results for memories you just saved, this release fixes that. If you've been hitting `hermes memory status` reporting "not available" against a healthy systemd-managed install, this release fixes that too. + +### Fixed + +- **BM25 search now indexes memories saved via `memory_save`.** `mem::remember` was writing to `KV.memories` but never calling `getSearchIndex().add()`, so `memory_smart_search` and `memory_recall` returned empty for everything saved through that path — for **every** version since v0.9.0. Synthesizes a `CompressedObservation` from the saved Memory (title + content + concepts + files) and adds it to BM25 right after the durable write. `rebuildIndex()` now walks `KV.memories` so a fresh rebuild covers the full corpus, and a startup backfill retroactively indexes pre-existing memories on first start after upgrade — no manual reindex required. New `SearchIndex.has(id)` is the idempotency gate. (#258, closes [#257](https://github.com/rohitg00/agentmemory/issues/257) — thanks @Nizar-BenHamida for the precise repro and log capture) + +- **Embedding providers no longer silently corrupt the vector index when an API returns wrong-dimension vectors.** `cosineSimilarity` returns `0` on length mismatch instead of throwing, so a wrong-size vector got stored, never matched anything, and the corresponding memory became invisible without a single log line. `withDimensionGuard()` now wraps every embedding provider at the factory boundary in `src/providers/embedding/index.ts` — `embed()`, `embedBatch()` (per-vector, indexed errors like `embedBatch[3]`), and `embedImage()` all throw a descriptive error when the returned `Float32Array` length doesn't match `provider.dimensions`. The persistence-restore path got the same defense: `IndexPersistence.load()` now refuses to start when persisted vectors mismatch the active provider, with an actionable error spelling out the recovery paths (re-embed / `AGENTMEMORY_DROP_STALE_INDEX=true` / switch back). (#248, closes [#247](https://github.com/rohitg00/agentmemory/issues/247) and [#256](https://github.com/rohitg00/agentmemory/issues/256) — thanks @AmmarSaleh50 for the issue analysis, the fix PR, and the test coverage) + +- **Hermes plugin: `handle_tool_call` now returns JSON strings, not raw Python dicts.** Hermes stores the return value as the tool result `content` field in session history. Anthropic-protocol providers reject non-string content with a 400 on the next request — once triggered, every subsequent request in the affected session 400s until the session JSON is hand-cleaned. Wrapped all four return paths (`memory_recall`, `memory_save`, `memory_search`, unknown-tool) in `json.dumps()` and tightened the return-type annotation `Any → str` on both the abstract base and the concrete class. Matches the contract that `src/mcp/standalone.ts` already honors. (#255, closes [#254](https://github.com/rohitg00/agentmemory/issues/254) — thanks @KyoMio for the Anthropic-protocol-specific repro) + +- **Hermes plugin: `hermes memory status` now reflects the real service state on systemd / launchd installs.** When agentmemory runs as an external service whose runtime config lives in `~/.agentmemory/.env`, those values never reach the Hermes CLI shell. Hermes status reads `os.environ` against `get_config_schema()`'s `env_var` keys, finds them unset, and reports the plugin as "not available" — even though the service is healthy. The plugin now preloads `~/.agentmemory/.env` at import time using `os.environ.setdefault`, bridging the agentmemory-managed and Hermes-managed config source-of-truths. Anything explicitly exported in the shell still wins. Best-effort: malformed / absent file is silently skipped. Both `~/.agentmemory/.env` and `$XDG_CONFIG_HOME/agentmemory/.env` are checked. (#253, closes [#250](https://github.com/rohitg00/agentmemory/issues/250) — thanks @OptionalCoin for the systemd repro and tracing it to env-source divergence) + +- **Hermes plugin: memory provider hooks accept passthrough kwargs.** Hermes calls memory provider hooks with extra context kwargs (e.g. `session_id`) at runtime that the existing strict signatures rejected with `sync_turn() got an unexpected keyword argument 'session_id'`. Hooks "succeeded" from Hermes's perspective but every conversation turn silently failed sync. Added `**kwargs: Any` to `sync_turn`, `on_session_end`, `on_pre_compress`, `on_memory_write`, `prefetch`, `queue_prefetch`, and `shutdown`. Where Hermes passes `session_id`, the patch prefers it over the cached `self._session_id` so multi-session gateway contexts route to the right session. Same change applied to the abstract `MemoryProvider` fallback for the import-error path. (#252, closes [#249](https://github.com/rohitg00/agentmemory/issues/249) — thanks @OptionalCoin for the precise log analysis) + +- **`agentmemory demo` now actually seeds observations.** `seedDemoSession` posted to `/agentmemory/observe` without `project` and `cwd`, which the API requires as non-empty strings, so every observation 400'd and the demo silently reported "Seeded 0 observations across 3 sessions". Two-line fix: re-stage `project` + `cwd` into the observe payload alongside `sessionId`. The smart-search queries the demo prints will now return real hits. (#251, closes [#229](https://github.com/rohitg00/agentmemory/issues/229) — thanks @seishonagon for the precise root-cause analysis) + +- **LLM compression / summarization timeouts increased.** Larger sessions were hitting the 120s consolidation timeout under heavier workloads, leaving partial state. Bumped per-step ceilings to give slow providers (esp. local models) room to finish. (#213 — thanks @xuli500177) + +- **`pi` / OpenClaw / Hermes integration fixes.** Tested round-trip fixes across the three integration plugins to keep them aligned with the latest hooks contract. (#230 — thanks @deepmroot) + +### Changed + +- **`iii-engine` pinned to v0.11.2 across every install path.** v0.11.6 introduces a new architecture where workers run inside sandboxed microVMs registered via `iii worker add`. agentmemory still uses the older `iii-exec watch + node dist/index.mjs` worker model from `iii-config.yaml`, which doesn't pass the new engine's stricter trigger validation cleanly — the worker drops into an EPIPE reconnect loop and recall stops working. Pinning to v0.11.2 (the last engine that runs agentmemory's current architecture cleanly) until we refactor agentmemory to register itself via `iii worker add` and run inside the new sandbox model. + - `src/cli.ts` auto-installer downloads `github.com/iii-hq/iii/releases/download/iii/v0.11.2/iii-.tar.gz` directly. Per-arch coverage: darwin arm64/x64, linux x64/arm64/armv7, win32 x64/arm64. + - Docker fallback pulls `iiidev/iii:0.11.2` instead of `:latest`. + - `docker-compose.yml` uses `image: iiidev/iii:${AGENTMEMORY_III_VERSION:-0.11.2}` so the override env var actually takes effect for compose users. + - Install instructions and Windows guide updated to point at the v0.11.2 release page. + - **Escape hatch:** `AGENTMEMORY_III_VERSION=` overrides the pin for users who've moved to the sandbox model manually. + - Windows ZIP path detection in `runUpgrade` so the auto-installer doesn't try to pipe a `.zip` through `tar -xz`. (#260) + - **Follow-up tracked separately:** refactor agentmemory to register as a sandboxed worker via `iii worker add` so the pin can be lifted. + +- **README documents how to extend agentmemory with `iii worker add`.** New "Powered by iii" section maps each `iii worker add ` to a concrete agentmemory capability — multi-instance memory, scheduled consolidation, durable retries on embeddings, sandboxed code exec, SQL state, extra MCP host. Lists only workers actually published to [workers.iii.dev](https://workers.iii.dev) with direct links. (#242) + +- **README iii Console section corrected.** The console ships with `iii` as a subcommand; there's no separate installer. Replaced the bogus `curl install.iii.dev/console/main/install.sh` line, simplified the launch command to `iii console --port 3114`, and added the missing console pages to the capability table (Workers, Queues, Config, Flow). Replaced the dashboard screenshot with the Workers page so users see real agentmemory instances connected. (#243) + +### Notes + +If you're upgrading from <0.9.5 and have an existing vector index on disk, the new dim-guard will refuse to load if your active embedding provider declares a different dimension than what's persisted. This is the intended safe default — set `AGENTMEMORY_DROP_STALE_INDEX=true` to discard and rebuild from live observations, or re-embed against the new provider before starting. + +If you've been on `iii-engine` v0.11.6 and noticed search returning empty after save, install agentmemory 0.9.5 fresh (or run `npx @agentmemory/agentmemory upgrade`) to pull pinned engine v0.11.2. v0.11.6 brings a new sandbox-everything-via-`iii worker add` model that agentmemory hasn't been refactored for yet — that work is tracked as a follow-up; this release just keeps existing users unblocked. + +[0.9.6]: https://github.com/rohitg00/agentmemory/compare/v0.9.5...v0.9.6 +[0.9.5]: https://github.com/rohitg00/agentmemory/compare/v0.9.4...v0.9.5 + +## [0.9.4] — 2026-04-29 + +Bug-fix patch. Fixes a silent gap where the knowledge graph never auto-populated despite `GRAPH_EXTRACTION_ENABLED=true`, and adds a doctor check that detects when Claude Code fails to load plugin hooks. + +### Fixed + +- **`mem::graph-extract` now auto-fires at session end.** When `GRAPH_EXTRACTION_ENABLED=true`, the function was registered and the REST endpoint was live, but no internal caller invoked it — the graph KV stayed empty unless users manually `POST`ed to `/agentmemory/graph/extract`. `event::session::stopped` now triggers it (fire-and-forget, idempotent via existing node/edge merge keys), so enabling the flag actually populates the graph. README pipeline diagram updated to show graph extraction at the Stop/SessionEnd phase rather than implying it runs per PostToolUse. (#210) + +### Added + +- **`agentmemory doctor` detects Claude Code plugin-hook load state.** Scans `~/.claude/debug/latest` for the `Loaded hooks from standard location for plugin agentmemory` line. Surfaces the silent failure mode where the plugin is enabled but Claude Code never registered the hooks — users previously got no signal, hooks just silently did nothing. Hint points at reinstall + session restart and the CC version floor (>= 2.1.x). Skips silently when `~/.claude/debug` is absent. (refs #212) + +[0.9.4]: https://github.com/rohitg00/agentmemory/compare/v0.9.3...v0.9.4 + +## [0.9.3] — 2026-04-24 + +Developer-experience patch. Every disabled feature flag is now visible in the viewer, the CLI, and REST error responses, so devs no longer hit empty tabs wondering whether the install is broken or just opt-in. Adds a `doctor` command that diagnoses the whole stack in one shot and a first-run hero in the viewer that points at the magical-moment `demo` command. + +### Added + +- **`agentmemory doctor` command.** Runs 10 diagnostic checks in one shot: server reachability, health status, viewer port, LLM provider, embedding provider, four feature flag states, and whether the knowledge graph has data. Every failing check includes a concrete hint with the exact env var or command to fix it. Mirrors the shape of the new viewer feature-flag banners. +- **`/agentmemory/config/flags` REST endpoint.** Returns `{ version, provider, embeddingProvider, flags[] }` with per-flag `{ key, label, enabled, default, affects, needsLlm, description, enableHow, docsHref }`. Used by the viewer banner, CLI status/doctor, and anyone who wants to introspect config without parsing logs. +- **Viewer feature-flag banner system.** Compact collapsible summary row at the top of every tab (`⚠ 3 off · ⚙ 1 note · Feature flags — click to expand`). Expanded view shows per-flag card with description, exact enable command, docs link, and dismiss button. Dismissed state persists per-flag in localStorage so banners stay out of the way once acknowledged. Banners filter by the current tab's `affects` list. +- **Viewer first-run hero card.** When `sessions.length === 0`, dashboard renders an orange-accent card titled "First run → magical moment in 10 seconds" with `npx @agentmemory/agentmemory demo` as the next step. Removes the dead-empty dashboard that used to greet fresh installs. +- **Viewer footer with preset issue report.** `agentmemory viewer · v{version} · github · docs · report issue →`. The feedback link opens a GitHub issue pre-filled with version, provider name, embedding provider, flag state, and user-agent — so the first message on an issue already contains the diagnostic context that used to take three back-and-forths. +- **Richer empty states on Actions, Memories, Lessons, Crystals tabs.** Each now has a titled lead explaining what the tab is for, why it's empty, three concrete ways to populate it (MCP tool, curl, hook), and a docs link. The old one-liners ("No actions yet. Create actions via memory_action_create MCP tool") assumed too much context. +- **`status` command shows flag state.** New section in the output block lists provider (`✓ llm` / `✗ noop`), embedding provider (`✓ embeddings` / `bm25-only`), and each flag with a tick/cross. Parity with the viewer banner. +- **`AGENTMEMORY_URL` environment variable honored by CLI.** `status`, `doctor`, and related health checks now respect `AGENTMEMORY_URL=http://host:port` and extract the port from it. Previously documented but silently ignored; `--port N` was the only way to override. +- **Website install section promotes `demo` to step 2.** `npx @agentmemory/agentmemory demo` now appears between "start server" and "open viewer" on agent-memory.dev. The magical-moment command is on the critical path of the three-step install, not tucked into the README. +- **Website version auto-derived from repo package.json.** `gen-meta.mjs` picks up `src/version.ts` on `prebuild` and writes `website/lib/generated-meta.json`. Removes the stale-version drift that showed `v0.9.1` on the landing page after `v0.9.2` shipped. + +### Changed + +- **REST "feature not enabled" errors now return structured bodies.** Graph extraction (3 endpoints) and consolidation pipeline (1 endpoint) used to return `{ error: "Knowledge graph not enabled" }`. Now return `{ error, flag, enableHow, docsHref }` matching the viewer banner contract. Curl users get the same fix guidance as UI users. +- **Website install title: `THREE STEPS` → `THREE COMMANDS`.** Matches the new three-command install (`npx agentmemory`, `agentmemory demo`, `open viewer`). + +### Fixed + +- **Viewer banner scroll blocker.** Initial banner implementation rendered four full-height banner cards stacked above the dashboard, pushing all stats off-screen. Replaced with compact collapsible summary that takes ~40px of vertical space by default and only expands on click. + +[0.9.3]: https://github.com/rohitg00/agentmemory/compare/v0.9.2...v0.9.3 + +## [0.9.2] — 2026-04-22 + +Safety + import-pipeline patch. Kills the infinite Stop-hook recursion loop that burned Claude Pro tokens on unkeyed installs, repairs every empty viewer tab after `import-jsonl`, derives lessons and crystals automatically from imported sessions, and opens up OpenAI-compatible embedding endpoints. + +### Security + +- **Stop-hook recursion loop** ([#187](https://github.com/rohitg00/agentmemory/pull/187), follow-up to [#149](https://github.com/rohitg00/agentmemory/issues/149)). A user with no provider key and `AGENTMEMORY_AUTO_COMPRESS=false` could still trigger unbounded recursion: Stop hook → `/summarize` → `provider.summarize()` → agent-sdk provider spawned a Claude Agent SDK child session that inherited the same plugin hooks, whose own Stop fired, spawning another child, etc. ~579 ghost `entrypoint: sdk-ts` sessions could accumulate in minutes, draining the Claude Pro subscription. Fixed at five layers in defense-in-depth: + 1. `detectProvider()` treats empty-string keys (`ANTHROPIC_API_KEY=`) as unset and returns the noop provider by default. The agent-sdk fallback now requires explicit `AGENTMEMORY_ALLOW_AGENT_SDK=true` opt-in with a second loud warning. + 2. New `NoopProvider` returns empty strings for compress/summarize; callers detect `.name === "noop"` and short-circuit. + 3. `agent-sdk` provider sets `AGENTMEMORY_SDK_CHILD=1` before spawning `query()` and restores the previous value in `finally` so later calls in the same parent process are not mis-classified. + 4. All 12 hook scripts inline a shared `isSdkChildContext(payload)` guard that checks both the env marker and `payload.entrypoint === "sdk-ts"`, and bail early. + 5. `/summarize` short-circuits with `{ success: false, error: "no_provider" }` when `provider.name === "noop"` instead of calling through. Empty provider responses are now logged and recorded as failures on the metrics store. + +### Added + +- **`OPENAI_BASE_URL` / `OPENAI_EMBEDDING_MODEL`** ([#186](https://github.com/rohitg00/agentmemory/pull/186), thanks @Edison-A-N). The `OpenAIEmbeddingProvider` now accepts a base URL override and a configurable model name, mirroring the `MINIMAX_BASE_URL` pattern. Unlocks Azure OpenAI, vLLM, LM Studio, and other OpenAI-compatible proxies for embeddings with zero breakage — defaults are preserved. +- **`OPENAI_EMBEDDING_DIMENSIONS`** ([#189](https://github.com/rohitg00/agentmemory/pull/189)). Follow-up: `dimensions` is now derived from the model via a `MODEL_DIMENSIONS` lookup (3-small=1536, 3-large=3072, ada-002=1536) and falls back to 1536 for unknown models. Custom or self-hosted OpenAI-compatible models should set this env var explicitly; non-positive values are rejected at construction. +- **Auto-derived lessons and crystals on `import-jsonl`** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). Each imported session now produces one crystal (narrative, tool outcomes, files, lessons) and up to 20 heuristic lessons from instructional patterns (`always`/`never`/`don't`/`prefer`/`avoid`/`caveat`/`note`/`warning`). Lessons are keyed by `fingerprintId("lesson", content.toLowerCase())` so re-importing the same file bumps `reinforcements` on existing lessons instead of duplicating rows. Crystals are keyed by `fingerprintId("crystal", sessionId)` and preserve `createdAt` on upsert. +- **Session preview on the sessions list** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). `Session` gained `firstPrompt` / `summary` fields; both `import-jsonl` and the live `mem::observe` path populate `firstPrompt` from the first real user prompt they see, and the viewer renders it as a 140-char preview row under each session. +- **Richer session detail + crystals viz + lessons tab explainers** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). Clicking a session now fetches its observations and renders a 4-stat grid (observations / tools / files / duration), top-10 tool bar chart, activity breakdown, and file list. Crystals cards show resolved lesson content instead of raw IDs. Lessons tab has a header explainer card for the rule + confidence + decay model. + +### Changed + +- **`detectProvider()` default is now `noop`** (see Security). Users who had no API key and relied on the implicit Claude-subscription fallback must set `AGENTMEMORY_ALLOW_AGENT_SDK=true` to restore old behavior — and should read the warning about Stop-hook recursion first. +- **`/agentmemory/audit` response shape** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). Now returns `{ entries, success }` instead of a bare array to match the viewer's expected shape. The viewer was rendering empty despite populated data. +- **`/agentmemory/replay/sessions` path** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). Calls `kv.list` directly instead of `sdk.trigger → mem::replay::sessions`. Sub-50ms on 600+ sessions instead of timing out at 10s+. +- **Viewer WebSocket connect timeout** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). 5-second timeout around `new WebSocket(...)`. If the socket is still CONNECTING after that, it is force-closed so the `onclose` retry / polling-fallback chain kicks in. Previously the banner stuck on `CONNECTING…` forever when the iii-stream port accepted TCP but never completed the upgrade handshake. +- **`import-jsonl` now runs synthetic compression + BM25 indexing** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). Imported observations go through the same `buildSyntheticCompression` + `getSearchIndex().add()` path as live `mem::observe`. Previously the raw shape was written directly to KV and the search index never saw it — consolidation reported "fewer than 5 summaries" and semantic/procedural/memory tabs stayed empty. +- **Viewer strength gauge** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). Memory tab showed `700%` on `strength: 7` because the scale was treated as 0–1. Now handles both 0–1 and 0–10 and clamps at 100%. + +### Fixed + +- **`npm ci` on fork PRs** ([#187](https://github.com/rohitg00/agentmemory/pull/187), [#188](https://github.com/rohitg00/agentmemory/pull/188)). CI failed because lockfiles are gitignored at the repo level. `.github/workflows/ci.yml` + `publish.yml` now run a two-step install: `npm install --package-lock-only` to produce a lockfile in the runner workspace, then `npm ci` to install deterministically from it. Gives a single resolved dependency graph across build + test + publish within one job run — important because publish uses `--provenance`. +- **`image-quota-cleanup` fail-closed on refCount read errors** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). When `getImageRefCount` threw, the code fell through to `deleteImage` with `refCount === 0`, risking deletion of still-referenced images on transient KV errors. Fail-closed: log + return from the `withKeyedLock` callback, never reach `deleteImage` without a confirmed zero refcount. +- **`raw.userPrompt` type guard** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). `mem::observe` now runtime-checks `typeof raw.userPrompt === "string"` before calling `.replace` / `.trim` / `.slice`. Non-string truthy values from malformed hook payloads no longer crash the handler. +- **Viewer Actions frontier field** ([#188](https://github.com/rohitg00/agentmemory/pull/188)). The tab was reading `results[1].actions` but `/frontier` returns `{ frontier: [...] }`. Fixed the read path; preserves actions/frontier unification. +- **Hardcoded `maxTokens: 4096` in the agent-sdk branch of `detectProvider`** ([#188](https://github.com/rohitg00/agentmemory/pull/188), [#190](https://github.com/rohitg00/agentmemory/pull/190)). Ignored the `maxTokens` variable computed from `env["MAX_TOKENS"]`. Every other branch already used the computed value; agent-sdk now matches. + +### Infrastructure + +- `StateScope` interface in `types.ts` documents the `KV.state` scope shape (`system:currentDiskSize: number`); `disk-size-manager` uses `StateScope[typeof DISK_SIZE_KEY]` generics instead of ad-hoc ``. +- `onnxruntime-node` + `onnxruntime-web` moved to `optionalDependencies` alongside `@xenova/transformers` to make their lazy/transitive nature explicit; still externalized in `tsdown.config.ts` because bundling breaks the native `.node` binding paths. +- `FALLBACK_PROVIDERS` parsing now honors the same `AGENTMEMORY_ALLOW_AGENT_SDK` gate as `detectProvider`, filtering out `agent-sdk` from the fallback chain unless explicitly opted in. +- README provider table + env block updated: no-op is the new default, Claude-subscription fallback moved to a separate opt-in row, OpenAI env vars documented. +- Hero stat badge refreshed from 654 → 827 tests (both dark + light variants). +- `VERSION` / `ExportData.version` union / `supportedVersions` Set / `test/export-import.test.ts` / `@agentmemory/mcp` shim version all bumped in lockstep. +- Test count: 827 (up from 812 in v0.9.1). + +[0.9.2]: https://github.com/rohitg00/agentmemory/compare/v0.9.1...v0.9.2 + +## [0.9.1] — 2026-04-21 + +Trust-the-CLI patch. Three bugs that surfaced in real testing of v0.9.0: the dashboard viewer showed zeros for half its cards, the `import-jsonl` command crashed on anything but a perfect response, and `upgrade` hard-aborted on a cargo registry that never had the crate. + +### Fixed + +- **Viewer dashboard list endpoints** ([#172](https://github.com/rohitg00/agentmemory/pull/172)). `GET /agentmemory/semantic` and `GET /agentmemory/procedural` were never registered, and `GET /agentmemory/relations` returned 405 because only the POST trigger existed. The dashboard's `Promise.all` fan-out silently received null for those cards even when semantic, procedural, or relation data was present. Added `api::semantic-list`, `api::procedural-list`, and `api::relations-list` handlers next to `api::memories` in `src/triggers/api.ts`, each returning the shape the viewer already parses. +- **CLI version drift** ([#173](https://github.com/rohitg00/agentmemory/pull/173)). The viewer brand badge hardcoded `v0.7.0` and the README "New in" banner still said `v0.8.2`. Replaced the viewer string with a `__AGENTMEMORY_VERSION__` placeholder substituted at render time by `document.ts` (same mechanism as the CSP nonce). Collapsed `src/version.ts` from a literal union of every historical release back to a single `VERSION` constant — the import-compat contract is the `supportedVersions` Set in `export-import.ts`, not the type. +- **`import-jsonl` crashed with `Unexpected end of JSON input`** ([#174](https://github.com/rohitg00/agentmemory/pull/174)). The livez probe used fetch throws as the only failure signal — any stray service on port 3111 passed silently, then `res.json()` blew up when the real POST returned an empty body or HTML error. Probe now captures `probe.status` + body snippet on non-OK responses and the exception message on network failure, so the error distinguishes `unreachable (...)` from `reachable but unhealthy (HTTP 503: ...)`. The POST reads body as text, parses only if non-empty, requires `json.success === true`, and maps 401 → "set AGENTMEMORY_SECRET" and 404 → "upgrade server to v0.8.13+". +- **`upgrade` aborted on `cargo install iii-engine`** ([#174](https://github.com/rohitg00/agentmemory/pull/174)). The crate was never published — the old flow called `requireSuccess`, which exited before the Docker pull ran. Swapped to the official installer used throughout the README and demo command: `curl -fsSL https://install.iii.dev/iii/main/install.sh | sh`. Installer failure is optional; a warn points at `iiidev/iii:latest` and the releases page at `iii-hq/iii`. + +### Infrastructure + +- Three integration tests cover the new list endpoints. +- `VERSION` / `ExportData.version` union / `supportedVersions` / `test/export-import.test.ts` all bumped in lockstep. + +[0.9.1]: https://github.com/rohitg00/agentmemory/compare/v0.9.0...v0.9.1 + +## [0.9.0] — 2026-04-18 + +Visibility + correctness release. Landing site, filesystem connector, MCP standalone now actually talks to the running server, health logic stops crying wolf, audit trail closes its last gap, and every memory path has a clear policy. + +### Added +- **Website** ([#164](https://github.com/rohitg00/agentmemory/pull/164)). Next.js 16 App Router landing page at `website/` — Lamborghini-inspired dark canvas, live GitHub stars pill, agents marquee with real brand logos, command-center tab showcase (viewer · iii console · state · traces), 12-tile feature grid, 10-agent MCP install selector, universal MCP JSON + one-click Cursor/VS Code deeplinks. Deploys to Vercel with Root Directory = `website/`. +- **Filesystem connector** — new `@agentmemory/fs-watcher` package under `integrations/filesystem-watcher/` ([#163](https://github.com/rohitg00/agentmemory/pull/163), closes [#62](https://github.com/rohitg00/agentmemory/issues/62)). Node `fs.watch` based, no native deps. Emits valid `HookPayload` observations for every file change and delete, with debounce, default ignore list, text-file preview, bearer auth, and env-driven config. +- **Security advisory drafts** for v0.8.2 CVEs ([#118](https://github.com/rohitg00/agentmemory/pull/118)). Six markdown drafts under `.github/security-advisories/` covering viewer XSS, curl-sh RCE, default 0.0.0.0 bind, unauthenticated mesh sync, Obsidian export traversal, and incomplete secret redaction. Also documents the symlink-traversal limitation of the Obsidian export fix. +- **iii console documentation** in the README ([#157](https://github.com/rohitg00/agentmemory/pull/157)). How to launch the iii console alongside the viewer, what each page gives you for agentmemory, and the `iii-observability` config that ships turned on. + +### Changed +- **Audit policy codified** ([#162](https://github.com/rohitg00/agentmemory/pull/162), closes [#125](https://github.com/rohitg00/agentmemory/issues/125)). `src/functions/audit.ts` gains a top-of-file policy block: every structural deletion emits a `recordAudit` row, scoped deletions (`governance-delete`, `forget`) write one row per call, bulk sweeps (`retention-evict`, `evict`, `auto-forget`) write one batched row per invocation. `mem::forget` no longer deletes silently — it writes a single audit row with target ids, session id, and per-type counts. +- **Standalone MCP talks to the running server** ([#161](https://github.com/rohitg00/agentmemory/pull/161), closes [#159](https://github.com/rohitg00/agentmemory/issues/159)). `@agentmemory/mcp` now probes `GET /agentmemory/livez` at `AGENTMEMORY_URL` (defaults to `http://localhost:3111`) on first tool call. If the server is up, every tool (sessions, smart-search, recall, save, governance-delete, export, audit) routes through REST and sees exactly what hooks and the viewer see. If the probe fails, falls back to the local `InMemoryKV` so pure-standalone setups keep working. Bearer `AGENTMEMORY_SECRET` attached automatically. Handle cache invalidates on proxy failure with a 30s TTL so a later server start is picked up. Response shapes are now consistent across proxy and local branches. +- **Retention eviction targets the right store** ([#132](https://github.com/rohitg00/agentmemory/pull/132)). `mem::retention-evict` now routes deletes to `mem:memories` or `mem:semantic` based on the candidate's `source` field, probing both namespaces when the field is missing (legacy rows). Emits a single batched audit row per sweep with `evictedIds`, `evictedEpisodic`, `evictedSemantic`, and the threshold. Retention scores gain a `source` field persisted to the store. + +### Fixed +- **Health stops flagging `memory_critical` on tiny Node processes** ([#160](https://github.com/rohitg00/agentmemory/pull/160), closes [#158](https://github.com/rohitg00/agentmemory/issues/158)). Memory severity no longer escalates from heap ratio alone. Both warn and critical bands now require RSS above `memoryRssFloorBytes` (default 512 MB). When heap is tight but RSS is below the floor, a non-alerting `memory_heap_tight_NN%_rssMMmb` note is attached to the snapshot — visibility without the false positive. +- **iii console screenshots vendored** in the README so the docs don't depend on CDN signed URLs. + +### Infrastructure +- `VERSION` union extended to `0.9.0`; `ExportData.version`, `supportedVersions`, and `test/export-import.test.ts` bumped in lockstep. +- `@agentmemory/mcp` dependency pinned at `~0.9.0` to match. +- Tests: 777 passing (+ 14 skipped), up from 769. + +[0.9.0]: https://github.com/rohitg00/agentmemory/compare/v0.8.12...v0.9.0 + +## [0.8.13] — 2026-04-17 + +### Added + +- Session replay: new "Replay" tab in the viewer that plays any stored session as a scrubbable timeline with prompt, response, tool-call, and tool-result events. Keyboard bindings: space to play/pause, arrow keys to step, speed selector (0.5×–4×). +- JSONL transcript import via `agentmemory import-jsonl [path]` CLI subcommand and `POST /agentmemory/replay/import-jsonl`. Default path `~/.claude/projects`, or pass an explicit file/directory. Imports are recorded in the audit log. +- New iii functions `mem::replay::load`, `mem::replay::sessions`, and `mem::replay::import-jsonl`, each routed through the same HMAC-authed API trigger as other endpoints. + +### Security + +- JSONL import rejects symlinks, paths containing sensitive terms (`secret`, `credential`, `.env`, etc.), and skips malformed lines without aborting the batch. + +## [0.8.12] — 2026-04-16 + +### Added + +- Added token-efficient `memory_recall` output modes: + - `format: "full"` (default) + - `format: "compact"` (returns compact observation rows) + - `format: "narrative"` (title + narrative text for low-token recall) +- Added `token_budget` support to `memory_recall` / `mem::search` to trim results to a target budget and return `tokens_used`, `tokens_budget`, and `truncated` metadata. +- Added new MCP + REST tool `memory_compress_file` (`mem::compress-file` / `/agentmemory/compress-file`) to compress markdown files while preserving headings, URLs, and fenced code blocks. + +### Changed + +- Updated MCP tool count to 44 and REST endpoint count to 104. +- Updated docs and plugin metadata for new tool/endpoint counts. +- Added test coverage for search formats, token budget behavior, and file compression validation. + +## [0.8.11] — 2026-04-15 + +**Fix**: `node dist/index.mjs` crashed on first import after the iii-sdk v0.11 migration (#116) merged. iii-sdk v0.11 dropped `getContext()`, but 32 `src/functions/*.ts` files still imported and called it. Added `src/logger.ts` (thin stderr shim with the same `.info/.warn/.error` signature) and mechanically replaced every `ctx.logger.*` call. Updated all 45 test mock blocks. Fixed `search.ts` `registerFunction` call to use the v0.11 string-ID API. + +### Fixed + +- **iii-sdk v0.11 getContext crash** ([#116](https://github.com/rohitg00/agentmemory/issues/116)) — `SyntaxError: The requested module 'iii-sdk' does not provide an export named 'getContext'` on startup. Removed all `getContext` imports from 32 function files, added `src/logger.ts` shim, updated 45 test mock blocks. + +### Changed + +- Upgraded `iii-sdk` dependency from `^0.11.0-next.8` to stable `^0.11.0`. +- Aligned stream send payloads with v0.11 wire format by using `type` for `stream::send` events in observe/compress/session-activity paths. +- Updated migration guidance/examples and diagnostics plugin registration snippets to v0.11 function registration and trigger request shapes. +## [0.8.10] — 2026-04-15 + +**Behavior change**: the PreToolUse and SessionStart hooks no longer run enrichment by default. SessionStart saves ~1-2K input tokens per session you start (the only path that was actually reaching the model, per the [Claude Code hook docs](https://code.claude.com/docs/en/hooks.md)). PreToolUse stops spawning a Node process and POSTing to `/agentmemory/enrich` on every file-touching tool call — a pure resource cleanup, not a token fix. If you were relying on either path, set `AGENTMEMORY_INJECT_CONTEXT=true` in `~/.agentmemory/.env` and restart. Observations are still captured via PostToolUse regardless. + +### Fixed + +- **Gate SessionStart context injection** ([#143](https://github.com/rohitg00/agentmemory/issues/143), thanks [@adrianricardo](https://github.com/adrianricardo)) — `src/hooks/session-start.ts` previously wrote ~1-2K chars of project context to stdout at every session start. Per the [Claude Code hook docs](https://code.claude.com/docs/en/hooks.md), `SessionStart` stdout is explicitly injected into the model's context ("where stdout is added as context that Claude can see and act on"), so this was adding real tokens to the first turn of every new session. Now gated behind `AGENTMEMORY_INJECT_CONTEXT`, default off. The session still gets registered for observation tracking — only the stdout echo is skipped. +- **Skip the PreToolUse enrichment round-trip when disabled** ([#143](https://github.com/rohitg00/agentmemory/issues/143)) — `src/hooks/pre-tool-use.ts` was POSTing `/agentmemory/enrich` on every `Edit`/`Write`/`Read`/`Glob`/`Grep` tool call and piping up to 4000 chars to stdout. The Claude Code docs make clear that PreToolUse stdout goes to the debug log, not the model context, so this was **not** burning user tokens — but it was spawning a Node process + full HTTP round-trip ~20x per user message with no effect on the conversation. Gating it makes the disabled hot path a ~15ms no-op Node startup instead of a ~100-300ms REST round-trip. **This is a resource cleanup, not a token fix**; leaving the gate in place protects forward in case Claude Code ever changes PreToolUse to inject stdout like SessionStart does. +- **`mem::retention-evict` no longer leaks semantic memories** ([#124](https://github.com/rohitg00/agentmemory/issues/124)) — the eviction loop was unconditionally calling `kv.delete(KV.memories, id)` for every below-threshold candidate, but retention scores are computed for both episodic (`KV.memories`) and semantic (`KV.semantic`) memories. When a candidate came from `KV.semantic`, the delete silently became a no-op (key wasn't in `mem:memories` to begin with) and the semantic row stayed alive forever with a sub-threshold score. Semantic memories could not be evicted by this path at all. Fix: add a `source: "episodic" | "semantic"` discriminator to `RetentionScore`, tag it at score creation, and branch the delete on `candidate.source`. For pre-0.8.10 rows with no `source` field (including semantic retention rows written by the old scorer), the loop probes both namespaces to find where the `memoryId` actually lives, so upgraded stores get their stranded semantic memories evicted without needing to re-score first. The response shape now also includes `evictedEpisodic` and `evictedSemantic` counts for observability. +- **`mem::retention-evict` now emits an audit record per sweep** ([#124](https://github.com/rohitg00/agentmemory/issues/124)) — retention eviction performs structural deletes (memories, retention scores, access logs) but was not calling `recordAudit()`, which made evictions invisible to audit consumers. Now batched one audit row per non-zero sweep, with `operation: "delete"`, `functionId: "mem::retention-evict"`, `targetIds` containing every evicted id, and `details.evicted` / `evictedEpisodic` / `evictedSemantic` / `threshold` for context. Zero-eviction sweeps intentionally do not write an audit row. + +### Honest note on #143 + +My initial diagnosis on the #143 thread pattern-matched too quickly to #138 and overclaimed that PreToolUse stdout was the smoking gun behind "Claude Pro burned in 4 messages". It wasn't — per the docs, PreToolUse stdout is debug-log only. The actual background cause is that [Claude Pro's Claude Code quotas are documented as tight](https://www.theregister.com/2026/03/31/anthropic_claude_code_limits/) and Anthropic has publicly confirmed "people are hitting usage limits in Claude Code way faster than expected." agentmemory contributes ~1-2K tokens per session via SessionStart, and that contribution is worth eliminating, but this release does not and cannot make Claude Pro's base quotas roomier. Users on heavy tool-call workloads should consider Max 5x or Team tiers regardless of whether agentmemory is installed. + +0.8.8's #138 fix (opt-in `mem::compress` via `AGENTMEMORY_AUTO_COMPRESS`) remains the correct fix for users with `ANTHROPIC_API_KEY` set — that path was a real per-observation Claude API burn and is unrelated to the Claude Code hook pipeline. + +### Added + +- **`AGENTMEMORY_INJECT_CONTEXT` env var** — default `false`. When `true`, restores the old SessionStart stdout write and the old PreToolUse `/enrich` round-trip. Startup banner prints a loud warning when it's on, mirroring the `AGENTMEMORY_AUTO_COMPRESS` warning from 0.8.8. +- **`isContextInjectionEnabled()`** helper in `src/config.ts` — single source of truth for the flag. The hooks read the env var directly (they're spawned as standalone `.mjs` files by Claude Code and don't bootstrap through `src/index.ts`), so the helper is there for the startup banner and future code paths. +- **5 subprocess regression tests** in `test/context-injection.test.ts` — spawns the compiled `pre-tool-use.mjs` and `session-start.mjs` hooks with real stdin/stdout pipes and asserts that stdout is empty when the env var is unset, when it's explicitly `false`, and that the disabled PreToolUse path exits under 1 second. Also asserts that the opt-in path with an unreachable backend still exits cleanly. Full suite: **724 passing** (was 719 + 5 new). + +### Infrastructure + +- **Startup banner** (`src/index.ts`) now prints `Context injection: OFF (default, #143)` on normal startup and a prominent WARNING when opt-in is enabled, so the mode is never silent. +- **Migration note**: if you were relying on the old SessionStart project-context injection or the old PreToolUse enrichment round-trip, add to `~/.agentmemory/.env`: + ```env + AGENTMEMORY_INJECT_CONTEXT=true + ``` + and restart Claude Code. You'll see the startup warning in the engine logs confirming it's active. + +[0.8.10]: https://github.com/rohitg00/agentmemory/compare/v0.8.9...v0.8.10 +[0.8.12]: https://github.com/rohitg00/agentmemory/compare/v0.8.11...v0.8.12 + +## [0.8.9] — 2026-04-14 + +Two UX fixes for the Claude Code plugin install path, both reported in [#139](https://github.com/rohitg00/agentmemory/issues/139) by [@stefanfaur](https://github.com/stefanfaur). + +### Fixed + +- **Claude Code plugin now auto-wires the `@agentmemory/mcp` stdio server** ([#139](https://github.com/rohitg00/agentmemory/issues/139)) — the plugin previously only shipped hooks and skills, and the README told Claude Code users to wire up the MCP server manually. A new `plugin/.mcp.json` declares the MCP server so `/plugin install agentmemory@agentmemory` auto-starts it when the plugin is enabled. No extra config step. +- **Skills no longer fail under Claude Code's sandbox with "Contains expansion"** ([#139](https://github.com/rohitg00/agentmemory/issues/139)) — the `recall` and `session-history` skills used pre-execution bash with `$(...)` / `${VAR:-default}` shell expansion, which Claude Code's sandbox rejects by pattern match. All four plugin skills (`recall`, `remember`, `forget`, `session-history`) are now rewritten as pure prompts that tell Claude to use the MCP tools directly. No bash, no sandbox issues, no shell escaping — and the skills run faster because they no longer fork a curl subprocess on every invocation. + +### Added + +- **Standalone MCP shim now implements `memory_smart_search` and `memory_governance_delete`** — the `@agentmemory/mcp` stdio server only exposed 5 tools (`memory_save`, `memory_recall`, `memory_sessions`, `memory_export`, `memory_audit`), so the rewritten plugin skills would have failed at runtime referencing tools the standalone didn't know about. Now ships 7 tools. `memory_smart_search` falls back to the same substring filter as `memory_recall` since the standalone shim doesn't have BM25/vector/graph without the full engine. `memory_governance_delete` takes `memoryIds` as an array or comma-separated string and returns `{deleted, requested, reason}`. +- **`memory_save` accepts `concepts`/`files` as arrays or comma-separated strings** — the old standalone only accepted CSV strings, which would silently drop array inputs. New `normalizeList()` helper handles both. +- **`memory_sessions` honours a `limit` arg** (default 20) — previously returned every session. +- **8 regression tests** in `test/mcp-standalone.test.ts` covering array/CSV inputs for `memory_save`, `memory_smart_search` substring fallback, `memory_sessions` limit, `memory_governance_delete` happy path + unknown-id skip + validation. Full suite: 715 passing. + +### Changed + +- **README Claude Code install snippet** — now explicitly notes that `/plugin install agentmemory` registers hooks + skills AND auto-wires the MCP server via `.mcp.json`, with no extra step. + +[0.8.9]: https://github.com/rohitg00/agentmemory/compare/v0.8.8...v0.8.9 + +## [0.8.8] — 2026-04-14 + +**Behavior change**: per-observation LLM compression is now opt-in. If you were relying on LLM-generated summaries (the old default), set `AGENTMEMORY_AUTO_COMPRESS=true` in `~/.agentmemory/.env` and restart. + +### Fixed + +- **Stop silently burning Claude API tokens on every tool invocation** ([#138](https://github.com/rohitg00/agentmemory/issues/138), thanks [@olcor1](https://github.com/olcor1)) — the old `mem::observe` path fired `mem::compress` unconditionally on every PostToolUse hook, which called Claude via the user's `ANTHROPIC_API_KEY` to turn each raw observation into a structured summary. An active coding session (50-200 tool calls/hour) could run through hundreds of thousands of tokens in minutes, which is the exact opposite of what a memory tool should do. The new default path skips the LLM call and uses a zero-token **synthetic compression** step that derives `type`, `title`, `narrative`, and `files` from the raw tool name, tool input, and tool output directly. Recall and BM25 search still work — you just lose the LLM-generated summaries unless you opt in. + +### Added + +- **`AGENTMEMORY_AUTO_COMPRESS` env var** — default `false`. When `true`, restores the old per-observation LLM compression path. The engine startup banner now prints a loud warning when it's on, reminding you that it spends tokens proportional to your session tool-use frequency. +- **`src/functions/compress-synthetic.ts`** — the new zero-LLM compression helper. `buildSyntheticCompression(raw)` maps tool names to `ObservationType` (via camelCase-aware substring matching for `Read`/`Write`/`Edit`/`Bash`/`Grep`/`WebFetch`/`Task`/etc.), pulls file paths out of `tool_input.file_path` / `pattern` / etc., and truncates narratives to 400 chars so one huge tool output can't blow up the BM25 index. +- **Regression test** `test/auto-compress.test.ts` — 8 cases covering the default path (no `mem::compress` trigger, synthetic observation stored in KV), explicit opt-in, tool-name-to-type mapping, file-path extraction, narrative truncation, and the `post_tool_failure` → `error` path. Full suite: 707 passing. + +### Infrastructure + +- **Startup banner** (`src/index.ts:171`) now prints either `Auto-compress: OFF (default, #138)` or a prominent warning when opt-in is enabled, so the mode is never silent. +- **Migration note**: if you were running 0.8.7 or earlier with `ANTHROPIC_API_KEY` set, your token usage will drop sharply on upgrade. Search quality may also drop slightly because narratives are now derived from raw tool I/O instead of Claude-generated summaries. If you want the old behavior: + ```env + # ~/.agentmemory/.env + AGENTMEMORY_AUTO_COMPRESS=true + ``` + and restart. Existing compressed observations in `~/.agentmemory/` are untouched. + +[0.8.8]: https://github.com/rohitg00/agentmemory/compare/v0.8.7...v0.8.8 + +## [0.8.7] — 2026-04-14 + +One-line fix for a brown-paper-bag bug reported in [#136](https://github.com/rohitg00/agentmemory/issues/136). + +### Fixed + +- **`npx @agentmemory/agentmemory` no longer crashes with "`/app/config.yaml` is a directory"** ([#136](https://github.com/rohitg00/agentmemory/issues/136), thanks [@stefano-medapps](https://github.com/stefano-medapps)) — the published tarball shipped `docker-compose.yml` but **not** `iii-config.docker.yaml`, even though the compose file mounts `./iii-config.docker.yaml:/app/config.yaml:ro`. Docker resolves missing host-path bind sources by silently creating them as empty directories, so the iii-engine container mounted an empty dir at `/app/config.yaml` and crashed with `Error: Failed to read config file '/app/config.yaml': Is a directory (os error 21)`. The `files` array in `package.json` now includes `iii-config.docker.yaml` alongside the regular `iii-config.yaml`. + +### Infrastructure + +- New regression test in `test/consistency.test.ts` parses every `./:` bind mount in `docker-compose.yml` and asserts the source file is shipped via the `files` array. Catches the class of bug where a new bind mount is added to compose without a corresponding entry in `files`. + +[0.8.7]: https://github.com/rohitg00/agentmemory/compare/v0.8.6...v0.8.7 + +## [0.8.6] — 2026-04-13 + +Finishes the `npx ` story from #120 by moving the standalone package under the `@agentmemory` scope. + +### Changed + +- **Standalone MCP shim is now `@agentmemory/mcp`** — the 0.8.5 publish attempted to push `agentmemory-mcp` as an unscoped package, but npm's name-similarity policy rejects it because of an unrelated third-party package called `agent-memory-mcp`. The shim now lives under the scope we already own, so `npx -y @agentmemory/mcp` works on the live registry. All README/integration/CLI-help snippets, the OpenClaw and Hermes guides, and the Claude-Desktop/Cursor/Codex/OpenCode MCP config examples have been updated to use the scoped name. The unscoped `agentmemory-mcp` command line (in the main package's `bin` field) was never published and has been removed from the docs. +- **Package directory renamed** `packages/agentmemory-mcp/` → `packages/mcp/`. The `.github/workflows/publish.yml` publish step points at the new path and `npm view @agentmemory/mcp` for the propagation check. +- **Log prefix** in `src/mcp/standalone.ts` and `src/mcp/in-memory-kv.ts` changed from `[agentmemory-mcp]` to `[@agentmemory/mcp]` so stderr output matches the package users install. + +### Fixed + +- **Shim version bump was missed in 0.8.5** — `packages/agentmemory-mcp/package.json` (now `packages/mcp/package.json`) was still pinned at `0.8.4` because the release bump script only touched the 8 files in the main package. The shim now tracks the main package and depends on `@agentmemory/agentmemory: ~0.8.6`. + +[0.8.6]: https://github.com/rohitg00/agentmemory/compare/v0.8.5...v0.8.6 + +## [0.8.5] — 2026-04-13 + +Compatibility fix for stricter JSON-RPC clients, plus a spec cleanup CodeRabbit caught during review. + +### Fixed + +- **MCP server works with Codex CLI and any strict JSON-RPC 2.0 client** ([#129](https://github.com/rohitg00/agentmemory/issues/129)) — the stdio transport was responding to JSON-RPC **notifications** (messages without an `id` field, e.g. `notifications/initialized`), which violates JSON-RPC 2.0 §4.1 and caused stricter clients like Codex CLI v0.120.0 to close the transport with "Transport closed". Notifications are now detected by the missing/null `id` field, the handler still runs for side effects, but no response is written. Handler errors on notifications are logged to stderr instead of sent back to the client. Claude Code and other clients that tolerated the spurious responses continue to work unchanged. +- **Request `id` type validation per JSON-RPC 2.0 §4** — the transport previously only checked `id != null`, so a malformed request with `id: {}` or `id: [1,2]` could get echoed back with that non-primitive id, and valid-shape requests with bad id types fell through to the handler and produced a response carrying a bogus non-JSON-RPC id. `isValidId()` now enforces `string | number | null | undefined`, and bad-id requests get `-32600 Invalid Request` with `id: null` before the handler runs. Caught by CodeRabbit on PR [#131](https://github.com/rohitg00/agentmemory/pull/131). + +### Infrastructure + +- 14 tests in `test/mcp-transport.test.ts` covering the request path, notification path (#129), malformed input, and id-type validation (object/array/boolean). Full suite: 698 passing. + +[0.8.5]: https://github.com/rohitg00/agentmemory/compare/v0.8.4...v0.8.5 + +## [0.8.4] — 2026-04-13 + +Two community contributions land on top of 0.8.3 and close out the #120 npm story for real. + +### Fixed + +- **Memories saved via the standalone MCP server now survive SIGKILL** ([#122](https://github.com/rohitg00/agentmemory/pull/122), thanks [@JasonLandbridge](https://github.com/JasonLandbridge)) — `memory_save` previously only flushed to `~/.agentmemory/standalone.json` on `SIGINT`/`SIGTERM`. If the MCP server process was killed forcefully (e.g. when an agent session ended), every memory saved during that session was lost. The save handler now persists to disk immediately after every `memory_save` call, so data survives unexpected termination. Also switched to the shared `generateId("mem")` helper and a single `isoNow` shared by `createdAt`/`updatedAt` so they can't drift. +- **OpenCode MCP config format corrected** ([#121](https://github.com/rohitg00/agentmemory/pull/121), thanks [@JasonLandbridge](https://github.com/JasonLandbridge)) — the README previously told OpenCode users to edit `.opencode/config.json` with an `mcpServers` object, but OpenCode actually uses `opencode.json` with an `mcp` object, `type: "local"`, and a `command` array. The agents table row and a new dedicated OpenCode block in the Standalone MCP section now document the correct format. + +## [0.8.3] — 2026-04-13 + +Two bug fixes reported in the public issue tracker. + +### Fixed + +- **Retention score now reflects real agent-side reads** ([#119](https://github.com/rohitg00/agentmemory/issues/119)) — `mem::retention-score` previously hardcoded `accessCount = 0` and `accessTimestamps = []` for episodic memories, and only used a single-sample `lastAccessedAt` for semantic memories. Reads from `mem::search`, `mem::smart-search`, `mem::context`, `mem::timeline`, `mem::file-context`, and the matching MCP tools (`memory_recall`, `memory_smart_search`, `memory_timeline`, `memory_file_history`) were never recorded, so the time-frequency decay formula was a dead path. The reinforcement boost is now driven by a real per-memory access log persisted at `mem:access`, written by every read endpoint (fire-and-forget, so reads never block on tracker writes), with a bounded ring buffer of the last 20 access timestamps. Pre-0.8.3 semantic memories that only have the legacy `lastAccessedAt` field still score correctly via a backwards-compat fallback. +- **`npx agentmemory-mcp` 404** ([#120](https://github.com/rohitg00/agentmemory/issues/120)) — the README told users to run `npx agentmemory-mcp` for MCP client setup, but `agentmemory-mcp` was only a `bin` entry inside `@agentmemory/agentmemory`, not a real package, so `npx` returned 404 from the npm registry. Two fixes: + - Published a new sibling package `agentmemory-mcp` (in `packages/agentmemory-mcp/`) that is a thin shim over `@agentmemory/agentmemory/dist/standalone.mjs`. `npx agentmemory-mcp` now works as documented. + - Added a canonical `npx @agentmemory/agentmemory mcp` subcommand to the main CLI for users who already have `@agentmemory/agentmemory` installed and don't want a second package on disk. Both commands do the same thing. + - README install snippets now use `npx -y agentmemory-mcp` so first-time users skip the install confirmation prompt. + +### Added + +- **Concurrent access tracking is race-safe** — the access log RMW is wrapped in the existing `withKeyedLock` keyed mutex, so two parallel reads of the same memory don't lose increments. `recordAccessBatch` uses `Promise.allSettled` so a slow keyed-lock acquisition on one id doesn't block the rest of the batch. +- **`mem::export` / `mem::import` now round-trip the access log** — the new `mem:access` namespace is included in dumps and restored on import, so backup/restore cycles no longer silently zero out reinforcement signals. +- **`exports` field in `package.json`** — explicitly exposes `./dist/standalone.mjs` as a subpath so the shim package and external consumers have a stable contract. +- **CI publishes both packages on release** — `.github/workflows/publish.yml` now publishes `@agentmemory/agentmemory` first, then the `agentmemory-mcp` shim from `packages/agentmemory-mcp/` so `npx agentmemory-mcp` works on the live release. + +## [0.8.2] — 2026-04-12 + +This release ships 6 security fixes, growth features, and a visual redesign of the README. Users on v0.8.1 should upgrade as soon as possible — the security fixes address vulnerabilities in default deployments. + +### Security + +Six vulnerabilities fixed, originally introduced before v0.8.1: + +- **[CRITICAL] Stored XSS in the real-time viewer** — viewer HTML used inline `onclick=` handlers while the CSP allowed `script-src 'unsafe-inline'`. User-controlled tool outputs could execute JavaScript in the reader's browser. Fixed by removing all inline event handlers, adding delegated `data-action` handling, switching to a per-response nonce-based CSP, and adding `script-src-attr 'none'`. +- **[CRITICAL] `curl | sh` in CLI startup** — the CLI auto-installed iii-engine via `execSync("curl -fsSL https://install.iii.dev/iii/main/install.sh | sh")`. Removed entirely. The CLI now uses an existing local `iii` binary if available, or falls back to Docker Compose. Users install iii-engine manually via `cargo install iii-engine` or Docker. +- **[HIGH] Default `0.0.0.0` binding** — `iii-config.yaml` bound REST (3111) and streams (3112) to all interfaces, exposing the memory store to anyone on the local network. Now binds to `127.0.0.1` by default. A separate `iii-config.docker.yaml` handles the Docker case with host port mapping restricted to `127.0.0.1:port`. +- **[HIGH] Unauthenticated mesh sync** — mesh push/pull endpoints accepted requests without an `Authorization` header. Mesh endpoints now require `AGENTMEMORY_SECRET`, and outgoing mesh sync requests send `Authorization: Bearer `. +- **[MEDIUM] Path traversal in Obsidian export** — the `vaultDir` parameter was passed directly to `mkdir`/`writeFile`, allowing writes to any filesystem path (e.g., `/etc/cron.d`). Exports are now confined to `AGENTMEMORY_EXPORT_ROOT` (default `~/.agentmemory`) via `path.resolve` + `startsWith` containment check. +- **[MEDIUM] Incomplete secret redaction** — the privacy filter missed `Bearer ...` tokens, OpenAI project keys (`sk-proj-*`), and GitHub fine-grained service tokens (`ghs_`, `ghu_`). Added regex coverage for all three formats. + +See GitHub Security Advisories for CVSS scores and affected version ranges. + +### Added + +- **`agentmemory demo` CLI command** — seeds 3 realistic sessions (JWT auth, N+1 query fix, rate limiting) and runs smart-search queries against them. Shows semantic search finding "N+1 query fix" when you search "database performance optimization" — the kind of result keyword matching can't produce. Zero config, 30 seconds, no integration needed. +- **`benchmark/COMPARISON.md`** — head-to-head comparison vs mem0 (53K⭐), Letta/MemGPT (22K⭐), Khoj (34K⭐), claude-mem (46K⭐), and Hippo. 18-dimension feature matrix, honest LongMemEval vs LoCoMo caveats, token efficiency table. +- **`integrations/openclaw/`** — OpenClaw gateway plugin with 4 lifecycle hooks (`onSessionStart`, `onPreLlmCall`, `onPostToolUse`, `onSessionEnd`). Same pattern as the existing Hermes integration. Includes README with paste-this-prompt block, `plugin.yaml`, and `plugin.mjs`. +- **Token savings dashboard** — `agentmemory status` now shows cumulative token savings and dollar cost saved (`$0.30/1K tokens` rate). Same card added to the real-time viewer on port 3113. +- **Paste-this-prompt blocks** — main README and both integration READMEs now open with a copy-pasteable text block users drop into their agent. The agent handles the entire setup (start server, update MCP config, verify health, open viewer). +- **60 custom SVG tags** — 30 dark-bg + 30 light-bg variants under `assets/tags/` and `assets/tags/light/`. Covers 14 section headers, 6 stat cards, 8 pill tags, and utility badges. GitHub README uses `` elements to auto-swap based on reader theme (dark theme → light-bg SVGs, light theme → dark-bg SVGs). +- **Real agent logos** in the Supported Agents grid — 16 agents with clickable brand logos (Claude Code, OpenClaw, Hermes, Cursor, Gemini CLI, OpenCode, Codex CLI, Cline, Goose, Kilo Code, Aider, Claude Desktop, Windsurf, Roo Code, Claude SDK, plus "any MCP client"). + +### Changed + +- README redesigned from plain markdown headers to SVG-tagged sections matching the agentmemory brand palette (orange `#FF6B35 → #FF8F5E` accent on dark `#1A1A1A` background). +- Hero stat row replaced with 6 custom SVG stat cards showing 95.2% R@5, 92% fewer tokens, 43 MCP tools, 12 auto hooks, 0 external DBs, 654 tests passing. +- Supported Agents grid reordered: Claude Code, OpenClaw, and Hermes now lead the first row (the 3 agents with first-class integrations in `integrations/`). +- Viewer token savings card now shows dollar cost saved alongside raw token count. +- Default configuration files updated: `iii-config.yaml` binds to `127.0.0.1`, new `iii-config.docker.yaml` for Docker deployments. + +### Fixed + +- **Viewer cost calculation was 100x under-reporting** — the formula `tokensSaved / 1000 * 0.3` returns dollars but was treated as cents. Now computes `costDollars` first, then `costCents = Math.round(costDollars * 100)`. 100K tokens now correctly displays `$30.00` instead of `30ct`. +- **`ObservationType` union missing `"image"`** — `VALID_TYPES` in `compress.ts` included `"image"` but the TypeScript union in `types.ts` didn't, breaking exhaustive checks. +- **Dynamic imports inside eviction loops** — `auto-forget.ts` and `evict.ts` called `await import("../utils/image-store.js")` inside nested loops. Hoisted once at the top of each function. +- **OpenClaw `/agentmemory/context` payload** — plugin was sending `{ tokenBudget, query, minConfidence }` but the endpoint expects `{ sessionId, project, budget? }`. Fixed to match the server contract. +- **Cursor cell in README grid** was missing its `Cursor` label. +- Codex CLI logo URL returned 404 from simple-icons CDN. Switched to GitHub org avatars for all logos for maximum reliability. + +### Infrastructure + +- 654 tests (up from 646 in v0.8.1), including 8 new tests covering viewer security, mesh auth, privacy redaction, and export confinement. +- All 60 custom SVGs validated with `xmllint` in CI-ready fashion. +- README consistency check updated to match new tool counts. + +--- + +## [0.8.1] — 2026-04-09 + +- Fix viewer not found when installed via npx (#109) + +## [0.8.0] — 2026-04-09 + +- Initial 0.8.x release + +--- + +[0.8.4]: https://github.com/rohitg00/agentmemory/compare/v0.8.3...v0.8.4 +[0.8.3]: https://github.com/rohitg00/agentmemory/compare/v0.8.2...v0.8.3 +[0.8.2]: https://github.com/rohitg00/agentmemory/compare/v0.8.1...v0.8.2 +[0.8.1]: https://github.com/rohitg00/agentmemory/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/rohitg00/agentmemory/releases/tag/v0.8.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..026795a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,48 @@ +# Code of Conduct + +agentmemory follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +The short version: + +- Be kind. Assume good faith. +- Disagree on the idea, not the person. +- Harassment — in issues, PRs, discussions, or any other project space — is not tolerated. +- Unwelcome behavior gets moderated first by reminder, then by time-out, then by removal. + +## Enforcement + +Reports go to **ghumare64@gmail.com** with subject `agentmemory CoC`. All reports are confidential. + +Responses follow the Covenant's enforcement ladder — correction, warning, temporary ban, permanent ban — and are decided by the project Maintainers listed in [MAINTAINERS.md](./MAINTAINERS.md). Where a Maintainer is a party to the report, that Maintainer recuses. + +### Escalation when no impartial Maintainer is available + +If every listed Maintainer recuses, or if the project is operating with a single Maintainer and the report concerns that Maintainer, the report is forwarded to an external neutral contact for independent adjudication. The current fallback chain, in order: + +1. **Contributor Covenant community ombudsperson** — email `ombudsperson@contributor-covenant.org` (see ). +2. **Hosting foundation abuse channel** — when agentmemory is accepted into a foundation (see `GOVERNANCE.md`), reports can be routed to that foundation's conduct committee instead. The current contact will be published here at that time. +3. **GitHub Trust & Safety** — for conduct that occurs inside GitHub spaces, the report can also be filed through . + +The external contact receives the original report verbatim (redacted only of third-party PII unrelated to the incident) and decides the enforcement step. The Maintainer body executes whatever enforcement action the external contact recommends. This ensures no report can dead-end because every internal reviewer is conflicted. + +## Scope + +This applies to every project space: + +- GitHub issues, PRs, discussions, and reviews on this repo. +- Any official chat channel that gets set up (currently none). +- Public representation of the project at conferences, meetups, and on social media. + +## Full text + +Reproduced verbatim from the Contributor Covenant 2.1 for convenience: + +> We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. +> +> We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +Full Covenant v2.1 text: + +## Attribution + +Contributor Covenant is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..39d38d6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contributing to agentmemory + +Thanks for taking an interest. This file is the short path from "I have an idea" to "it's in main." + +## Ground rules + +- Apache-2.0 license applies to every contribution. +- Sign-off is required on every commit (see [DCO](#developer-certificate-of-origin) below). +- Be civil. [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) applies. +- No attribution headers ("Generated with Claude Code", "Co-Authored-By: Claude", etc.) in commits or PR descriptions. + +## Before you open an issue + +Search existing issues first: + +- [open issues](https://github.com/rohitg00/agentmemory/issues?q=is%3Aissue+is%3Aopen) +- [closed issues](https://github.com/rohitg00/agentmemory/issues?q=is%3Aissue+is%3Aclosed) + +If it's a bug: provide the repro steps, your Node version, OS, agentmemory version (`npm view @agentmemory/agentmemory version`), and what you expected vs. what you saw. + +If it's a feature: describe the user problem before the implementation. "I couldn't X because Y" beats "please add X." + +## Before you open a PR + +1. Fork the repo and create a branch off `main`: + - `feat/` for features + - `fix/-` for bug fixes + - `docs/`, `refactor/`, `chore/` for the rest +2. `npm install` — you need Node >=20. +3. `npm run build` — TypeScript must compile clean. +4. `npm test` — the full test suite must pass. The one integration test under `test/integration.test.ts` needs a live server on `:3111` and is fine to skip locally. +5. Commit with sign-off. Rebase over tiny fixup commits so the history stays readable. + +## Pull request flow + +- Keep PRs small and focused. One logical change per PR. +- Write a clear description: what it does, why, and how to verify. +- Link the issue the PR resolves (`Fixes #NNN` / `Closes #NNN`). +- Expect CodeRabbit to review automatically. Address its comments before asking a human. +- Address review feedback in new commits (do not force-push to the same branch). Maintainers may squash on merge. +- A maintainer will merge when tests pass, CodeRabbit is green, and any review comments are addressed. + +## Developer Certificate of Origin + +Every commit must carry a `Signed-off-by` trailer stating you have the right to submit the contribution under Apache-2.0. The full text of the DCO is at . + +Add it automatically: + +```bash +git commit -s -m "feat: your message" +``` + +PRs with commits lacking sign-off will not merge. + +## Coding style + +- TypeScript strict mode. No `any` unless justified in a comment. +- Prettier-compatible formatting (editor on save is fine; no repo-wide hook). +- No code comments that restate what the code does. Only write a comment when the *why* is non-obvious — a hidden constraint, an invariant, a workaround for a specific bug. +- No dead code, no commented-out imports. +- Tests live next to the feature in `test/.test.ts`. Name the test after the behavior, not the implementation. + +## Subsystems at a glance + +| Directory | What lives here | +|-|-| +| `src/triggers/api.ts` | Every HTTP endpoint under `/agentmemory/*`. Adding an MCP tool? Add the REST twin here too. | +| `src/mcp/` | Standalone MCP server (`@agentmemory/mcp`), tools registry, transport, in-memory KV. | +| `src/functions/` | Core memory operations — observe, compress, consolidate, retention, forget, graph, smart-search, export-import, governance. | +| `src/hooks/` | The 12 auto-hooks that capture sessions in agents. | +| `src/health/` | Liveness + readiness + alert thresholds. | +| `src/state/` | KV schema, keyed mutex, access log. | +| `integrations/` | First-party plugins: `hermes/`, `openclaw/`, `filesystem-watcher/`. | +| `plugin/` | Claude Code plugin (`agentmemory@agentmemory`). | +| `website/` | Marketing site (Next.js 16). | +| `test/` | Vitest test suite. | + +## Adding an MCP tool + +1. Register the function in `src/functions/.ts`. +2. Register the HTTP trigger in `src/triggers/api.ts` with a matching `api_path`. +3. Add the tool entry in `src/mcp/tools-registry.ts`. +4. Implement in `src/mcp/standalone.ts` if the standalone MCP package should also expose it. +5. Write a test under `test/`. +6. No CHANGELOG touch in the PR itself — release PRs are the only place CHANGELOG changes. + +## Adding an auto-hook + +1. Add the new `HookType` string to the union in `src/types.ts`. +2. Wire the handler in `src/hooks/.ts`. +3. Add a Vitest case that fires the hook and asserts the observation gets written. + +## Release process + +Maintainers cut releases. Every bump touches 8 files in lockstep: + +1. `package.json` +2. `package-lock.json` (top + `packages[""].version`) +3. `plugin/.claude-plugin/plugin.json` +4. `packages/mcp/package.json` (self + `~x.y.z` pin on the main package) +5. `src/version.ts` (extend the union, assign) +6. `src/types.ts` (`ExportData.version` union) +7. `src/functions/export-import.ts` (`supportedVersions` Set) +8. `test/export-import.test.ts` (assertion) + +Then: CHANGELOG section, PR, merge, tag, GitHub release. The `Publish to npm` workflow picks up the release trigger and publishes `@agentmemory/agentmemory`, `@agentmemory/mcp`, and `@agentmemory/fs-watcher` to npm with provenance. + +## Security issues + +Do not open a public issue for a security report. See [SECURITY.md](./SECURITY.md). + +## Questions + +- Implementation questions: open a GitHub Discussion. +- Governance questions: open an issue labeled `governance`. See [GOVERNANCE.md](./GOVERNANCE.md). diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..aa9b12f --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,288 @@ +# Design System Inspired by Lamborghini + +## 1. Visual Theme & Atmosphere + +Lamborghini's website is a cathedral of darkness — a digital stage where jet-black surfaces stretch infinitely and every element emerges from the void like a machine under a spotlight. The page is almost entirely black. Not dark gray, not near-black — true, uncompromising black (`#000000`) that saturates the viewport and refuses to yield. Into this abyss, white type and Lamborghini Gold (`#FFC000`) are deployed with surgical precision, creating a visual language that feels like walking through a nighttime motorsport event where every surface absorbs light except the things that matter. + +The hero is a full-viewport video — dark, cinematic, immersive — showing event footage or vehicle reveals with the Lamborghini bull logo floating ethereally above. The navigation is minimal: a centered bull logo, a "MENU" hamburger on the left, and search/bookmark icons on the right, all rendered in white against the black canvas. There are no borders, no visible nav containers, no background color on the header — just white marks floating in darkness. The overall mood is nocturnal luxury: exclusive, theatrical, and deliberately intimidating. Each section transition is a scroll through darkness into the next revelation. + +Typography is the voice of this darkness. LamboType — a custom Neo-Grotesk typeface created by Character Type and design agency Strichpunkt — is used for everything from 120px uppercase display headlines to 10px micro labels. Its distinctive 12° angled terminals are inspired by the aerodynamic lines of Lamborghini's super sports cars, and its proportions range from Normal to Ultracompressed width. Headlines SHOUT in uppercase at enormous scales with tight line-heights (0.92 at 120px), creating dense blocks of text that feel stamped from steel. The typeface carries hexagonal geometric DNA — constructed from hexagons, three-armed stars, and circles — that echoes throughout the interface in the hexagonal pause button and UI icons. Built on Bootstrap grid with 68 Element Plus/UI components, the technical infrastructure is substantial beneath the theatrical surface. + +**Key Characteristics:** +- True black (`#000000`) dominant surfaces with white and gold as the only relief colors +- LamboType custom Neo-Grotesk font with 12° angled terminals inspired by aerodynamic car lines +- Lamborghini Gold (`#FFC000`) as the sole accent color — used exclusively for primary CTA buttons +- All-uppercase display typography at extreme scales (120px, 80px, 54px) with tight line-heights +- Full-viewport video heroes with cinematic event/vehicle content +- Zero border-radius on buttons — sharp, angular, uncompromising rectangles +- Hexagonal motifs in UI elements (pause button, icon system) echoing brand geometry +- Bootstrap grid system + Element Plus/UI 68 components underneath +- Transparent ghost buttons with white borders at 50% opacity as the secondary CTA pattern + +## 2. Color Palette & Roles + +### Primary +- **Lamborghini Gold** (`#FFC000`): The signature accent color — a warm, saturated amber-gold (rgb 255, 192, 0) used exclusively for primary action buttons ("Discover More", "Tickets", "Start Configuration"). The only chromatic color in the entire interface, it ignites against the black canvas like a headlight cutting through night +- **Pure White** (`#FFFFFF`): Primary text color on dark surfaces, logo rendering, nav elements, and light-mode button fills — the voice that speaks from the darkness + +### Secondary & Accent +- **Dark Gold** (`#917300`): Hover/pressed state for gold buttons — a deep amber (rgb 145, 115, 0) that darkens the gold to signal interaction +- **Gold Text** (`#FFCE3E`): Slightly lighter gold variant (rgb 255, 206, 62) used for inline text accents and highlighted labels +- **Cyan Pulse** (`#29ABE2`): Electric blue-cyan (rgb 41, 171, 226) appearing as an informational accent and interactive element highlight +- **Link Blue** (`#3860BE`): Medium blue (rgb 56, 96, 190) used universally for link hover states across all text colors + +### Surface & Background +- **Absolute Black** (`#000000`): The dominant surface color — used for page background, hero sections, header, footer, and most containers +- **Charcoal** (`#202020`): Elevated dark surface (rgb 32, 32, 32) — the primary "dark gray" for cards, panels, and text containers sitting above the black canvas +- **Dark Iron** (`#181818`): Subtle surface variant (rgb 24, 24, 24) — barely distinguishable from black, used for footer and deep sections +- **Overlay Black** (`rgba(0,0,0,0.7)`): Semi-transparent overlay for modals and video dimming +- **Near White** (`#F8F8F8`): Rare light surface (rgb 248, 248, 248) for content blocks in white-mode sections +- **Mist** (`#E6E6E6`): Light gray surface for secondary light-mode containers + +### Neutrals & Text +- **Pure White** (`#FFFFFF`): Primary text on dark backgrounds — headlines, body, nav labels +- **Smoke** (`#F5F5F5`): Secondary text on dark surfaces — slightly softer than pure white +- **Graphite** (`#494949`): Dark gray text on light surfaces (rgb 73, 73, 73) +- **Ash** (`#7D7D7D`): Mid-range gray for muted text, timestamps, and metadata (rgb 125, 125, 125) +- **Steel** (`#969696`): Lighter gray for disabled text and subtle labels (rgb 150, 150, 150) +- **Slate** (`#666666`): Alternative mid-gray for secondary content +- **Iron** (`#555555`): Dark mid-gray for body text variants +- **Shadow** (`#313131`): Very dark gray for text on dark surfaces where white is too strong + +### Semantic & Accent +- **Cyan Pulse** (`#29ABE2`): Used for informational highlights and interactive feedback +- **Link Blue** (`#3860BE`): Universal hover state for all hyperlinks +- **Teal Action** (`#1EAEDB`): Button hover background for transparent/ghost variants (rgb 30, 174, 219) + +### Gradient System +- No explicit gradients in the color palette — the dark-to-light progression is achieved through surface layering: `#000000` → `#181818` → `#202020` → `#494949` → `#7D7D7D` +- Video heroes use natural atmospheric gradients from the content itself +- Top-of-page gradient: subtle dark-to-darker fade at the edges of full-bleed imagery + +## 3. Typography Rules + +### Font Family +- **Display & UI**: `LamboType`, Roboto, Helvetica Neue, Arial — custom Neo-Grotesk typeface by Character Type for Lamborghini's 2024 brand refresh. Available in widths from Normal to Ultracompressed and weights from Light (300) to Black. Features 12° angled terminals inspired by aerodynamic car geometry, hexagonal construction logic, and support for 200+ languages including Latin, Cyrillic, and Greek +- **Fallback/UI**: `Open Sans` — used for some button/form contexts as system fallback +- **No italic variants** observed on the marketing site — the brand voice is always upright + +### Hierarchy + +| Role | Size | Weight | Line Height | Letter Spacing | Notes | +|------|------|--------|-------------|----------------|-------| +| Hero Display | 120px (7.50rem) | 400 | 0.92 | normal | LamboType, uppercase, maximum impact | +| Display 2 | 80px (5.00rem) | 400 | 1.13 | normal | LamboType, uppercase, major section titles | +| Section Title | 54px (3.38rem) | 400 | 1.19 | normal | LamboType, uppercase | +| Sub-section | 40px (2.50rem) | 400 | 1.15 | normal | LamboType, uppercase | +| Feature Heading | 27px (1.69rem) | 400 | 1.37 | normal | LamboType, uppercase | +| Card Title | 24px (1.50rem) | 400 | — | normal | LamboType | +| Body Large | 18px (1.13rem) | 400 | 1.56 | normal | LamboType, mixed case and uppercase variants | +| Body / UI | 16px (1.00rem) | 400/700 | 1.50 | normal/0.16px | LamboType, primary body text | +| Button Large | 16px (1.00rem) | 400 | 1.50 | normal | Gold CTA buttons | +| Button Standard | 14.4px (0.90rem) | 300/700 | 1.00 | 0.14–0.2px | LamboType, uppercase, ghost buttons | +| Button Small | 13px (0.81rem) | 300/500 | 1.20 | 0.13–0.2px | LamboType, compact button variant | +| Caption | 14px (0.88rem) | 600/700 | 1.14–1.50 | -0.42px | LamboType, uppercase, negative tracking | +| Label | 12px (0.75rem) | 400/500 | 1.83 | 0.96px | LamboType, uppercase badges and micro labels | +| Micro | 10px (0.63rem) | 400 | 1.00–2.00 | 0.225px | LamboType, uppercase, smallest text | + +### Principles +- **ALL-CAPS is the default voice**: Display and feature headings are universally uppercase. This creates a shouting, commanding tone that matches the brand's aggression +- **Extreme scale range**: From 120px heroes to 10px micro labels — a 12:1 ratio that creates dramatic visual hierarchy +- **Tight line-heights at scale**: Display sizes use 0.92-1.19 line-height, creating dense, compressed blocks of type that feel stamped rather than typeset +- **Weight 400 dominates**: Unlike many design systems that use bold for emphasis, Lamborghini's regular weight carries the headlines — the typeface itself is so distinctive it doesn't need weight variation +- **Negative tracking on captions**: -0.42px letter-spacing on 14px captions creates a compressed, technical aesthetic +- **Positive tracking on micro text**: +0.225px at 10px ensures legibility at the smallest sizes +- **Single typeface discipline**: LamboType handles everything — the 12° angled terminals and hexagonal geometry provide visual coherence across all sizes + +## 4. Component Stylings + +### Buttons +All buttons use **zero border-radius** — sharp, angular rectangles that echo the aggressive lines of Lamborghini vehicles. + +**Gold Accent CTA** — The primary action: +- Default: bg `#FFC000` (Lamborghini Gold), text `#000000`, padding 24px, fontSize 16px, fontWeight 400, borderRadius 0px, no border +- Hover: bg `#917300` (Dark Gold), darkens significantly +- Class: `btn-accent btn-large` +- Used for: "Discover More", "Tickets", "Start Configuration" + +**Transparent Ghost** — The secondary action on dark backgrounds: +- Default: bg transparent, text `#FFFFFF`, border 1px solid `#FFFFFF`, padding 16px, opacity 0.5 +- Hover: bg `#1EAEDB` (Teal Action), text white, opacity 0.7 +- Focus: bg `#1EAEDB`, border 1px solid `#000000`, outline 2px solid `#000000` +- Used for: secondary CTAs on hero sections and dark panels + +**White Filled** — Light-mode primary: +- Default: bg `#FFFFFF`, text `#202020`, no border +- Used for: CTAs on dark sections where gold isn't appropriate + +**Black Filled** — Dark filled variant: +- Default: bg `#000000`, text `#202020` +- Used for: Inverted CTA on light sections + +**Gray Neutral** — Subtle action: +- Default: bg `#969696`, text `#202020` +- Used for: secondary/tertiary actions, badge-like buttons + +### Cards & Containers +- Background: `#202020` (Charcoal) on black canvas, or `#000000` on lighter sections +- Border: `0px 1px solid #202020` bottom borders for section dividers +- Border-radius: 0px (completely sharp corners) +- Shadow: minimal, uses overlay opacity for depth +- Content: full-bleed photography + overlaid text in white + +### Inputs & Forms +- Minimal form presence on the marketing site +- Switch elements: border-radius 20px (the only rounded element), border 1px solid `#DDDDDD` +- Cookie banner input style: white text on black with `#7D7D7D` borders + +### Navigation +- **Desktop**: Centered bull logo, "MENU" hamburger with icon on left, search icon + bookmarks icon on right +- **Background**: Transparent (inherits black page background) +- **Sticky**: Fixed to top, floats above content +- **No visible borders or shadows** — elements float in the darkness +- **"MENU" label**: White text at 14px weight 400, uppercase, accompanies hamburger icon +- **Hexagonal motifs**: Pause button on hero sections uses hexagonal outline shape + +### Image Treatment +- **Hero**: Full-viewport video sections (100vh) with cinematic event/vehicle footage +- **Event photography**: Full-bleed aerial shots of Lamborghini Arena events +- **Vehicle imagery**: High-contrast studio shots on dark backgrounds, full-width +- **Aspect ratios**: Predominantly 16:9 and wider for cinematic feel +- **Dark gradient overlays**: Subtle darkening at top/bottom edges of video to ensure text legibility + +### Distinctive Components +- **Hexagonal Pause Button**: Video control uses a hexagonal outline (matching the brand's geometric DNA from the typeface), positioned bottom-right of hero sections +- **Progress Bar**: Thin white line at bottom of hero sections indicating video/slide progress +- **Badge/Tag**: bg `#969696`, text white, padding 8px, fontSize 10px, borderRadius 2px — tiny metallic pills + +## 5. Layout Principles + +### Spacing System +- **Base unit**: 8px +- **Full scale**: 2px, 4px, 5px, 8px, 10px, 12px, 15px, 16px, 20px, 24px, 32px, 40px, 48px, 56px +- **Button padding**: 16px (ghost), 24px (gold accent) +- **Section padding**: 48–56px vertical, 40px horizontal +- **Small spacing**: 2–5px for fine adjustments (badge padding, border spacing) + +### Grid & Container +- **Framework**: Bootstrap grid system (container + row + col) +- **Max width**: 1440px (largest breakpoint) +- **Columns**: Standard 12-column Bootstrap grid +- **Full-bleed**: Hero sections break out of grid to fill viewport edge-to-edge +- **Content areas**: Centered within 1200px max-width containers + +### Whitespace Philosophy +Lamborghini uses darkness as whitespace. The generous black expanses between content blocks serve the same function as white space in a light design — creating breathing room that elevates each element to the status of exhibit. A model name floating in the middle of a black viewport has the same visual weight as a gallery piece on a white wall. The absence of color IS the design. + +### Border Radius Scale +| Value | Context | +|-------|---------| +| 0px | Default for everything — buttons, cards, containers, images | +| 1px | Subtle span elements | +| 2px | Badges, close buttons, cookie elements — barely perceptible | +| 20px | Toggle switches only — the sole rounded element | + +## 6. Depth & Elevation + +| Level | Treatment | Use | +|-------|-----------|-----| +| Level 0 (Abyss) | `#000000` flat | Page background, deepest layer | +| Level 1 (Surface) | `#181818` or `#202020` | Cards, content panels, elevated sections | +| Level 2 (Overlay) | `rgba(0,0,0,0.7)` | Modal backdrops, video dimming | +| Level 3 (Fog) | `rgba(0,0,0,0.5)` | Lighter overlays, hover states | +| Level 4 (Mist) | `rgba(0,0,0,0.25)` | Subtle depth hints | + +### Shadow Philosophy +Lamborghini achieves depth through surface color layering rather than shadows. On a black canvas, traditional drop shadows are invisible — instead, the system creates elevation by shifting from absolute black to progressively lighter dark grays: `#000000` → `#181818` → `#202020` → `#494949`. This "darkness gradient" approach means that elevated elements are literally lighter than their surroundings, inverting the traditional shadow model. + +### Decorative Depth +- Full-bleed video provides atmospheric depth through cinematic lighting +- The hexagonal pause button floats with a thin white outline stroke +- Progress bars at hero section bottoms create a subtle horizon line +- No gradients, glows, or blur effects on UI elements — the photography provides all visual richness + +## 7. Do's and Don'ts + +### Do +- Use absolute black (`#000000`) as the primary background — never dark gray as a substitute +- Apply Lamborghini Gold (`#FFC000`) exclusively for primary CTA buttons — never for decorative purposes +- Set all display headings in uppercase with LamboType — the brand voice is always SHOUTING +- Use zero border-radius on buttons and cards — sharp angles are non-negotiable +- Maintain tight line-heights (0.92–1.19) on display type to create dense, architectural text blocks +- Use the transparent ghost button (white border, 50% opacity) as the secondary CTA on dark backgrounds +- Let full-viewport video/photography carry emotional weight — UI is infrastructure, not decoration +- Reserve hexagonal geometry for UI icons and the video control button +- Use weight 400 (regular) for headlines — the typeface is distinctive enough without bold emphasis +- Keep the gray palette achromatic — all neutrals are pure gray without color tinting + +### Don't +- Introduce additional accent colors beyond gold — the monochrome-plus-gold system is sacred +- Apply border-radius to buttons or cards — curved edges contradict the angular vehicle aesthetic +- Use LamboType in italic or decorative styles — the brand is always upright and direct +- Add gradients to buttons or surfaces — depth comes from surface layering, not blending +- Use light backgrounds as the primary canvas — darkness is the default state, light is the exception +- Mix lowercase into display headings — the uppercase convention communicates authority and power +- Add hover animations with scale or translate — interactions should be color-only (background/opacity shifts) +- Use Open Sans for display text — LamboType must handle all visible typography +- Create busy layouts with many small elements — Lamborghini's design is about singular, bold statements +- Apply shadows to elements — on a black canvas, shadows are meaningless; use surface color shifts instead + +## 8. Responsive Behavior + +### Breakpoints +| Name | Width | Key Changes | +|------|-------|-------------| +| Mobile Small | <425px | Single column, reduced type scale, stacked buttons | +| Mobile | 425-576px | Single column, hamburger nav, hero text ~40px | +| Tablet Small | 576-768px | 2-column grid begins, padding adjusts | +| Tablet | 768-1024px | 2-column layout, expanded hero, vehicle cards side-by-side | +| Desktop | 1024-1280px | Full navigation, 3+ column grids, display text at 80px | +| Desktop Large | 1280-1440px | Full layout, hero at 120px display, max-width containers | +| Wide | >1440px | Content centered, margins expand, hero fills viewport | + +### Touch Targets +- Gold CTA buttons: 48px+ minimum height with 24px padding (exceeds WCAG 44×44px) +- Ghost buttons: 48px+ with 16px padding +- Hamburger menu: large touch target (~48px square) +- Hexagonal pause button: approximately 48px diameter + +### Collapsing Strategy +- **Navigation**: Always hamburger-based ("MENU" + icon) — no horizontal nav expansion on any breakpoint +- **Hero video**: Maintains full-viewport height across all breakpoints, adjusting object-fit +- **Display type**: Scales from 120px (desktop) → 80px (tablet) → 54px/40px (mobile) +- **Button layout**: Side-by-side on desktop, stacks vertically on mobile +- **Grid columns**: 3-column → 2-column → 1-column progression +- **Section spacing**: Reduces from 56px → 40px → 24px vertical padding + +### Image Behavior +- Hero videos use `object-fit: cover` to maintain cinematic framing at all sizes +- Vehicle images scale within their containers with maintained aspect ratios +- Event photography crops to viewport width on narrow screens +- Background images darken at edges to maintain text contrast on all viewports + +## 9. Agent Prompt Guide + +### Quick Color Reference +- Primary CTA: "Lamborghini Gold (#FFC000)" +- Background: "Absolute Black (#000000)" +- Surface: "Charcoal (#202020)" +- Heading text: "Pure White (#FFFFFF)" +- Body text: "Ash (#7D7D7D)" +- Link hover: "Link Blue (#3860BE)" +- Accent: "Cyan Pulse (#29ABE2)" +- Border: "Pure White (#FFFFFF) at 50% opacity" + +### Example Component Prompts +- "Create a hero section with a full-viewport black background, the model name 'TEMERARIO' in LamboType at 120px uppercase weight 400 white text with 0.92 line-height, centered vertically, with a Lamborghini Gold (#FFC000) 'Discover More' button below — sharp corners, 0px radius, 24px padding, black text" +- "Design a transparent ghost button with 1px solid white border at 50% opacity, white text at 14.4px uppercase with 0.2px letter-spacing, padding 16px, on a black background — hover state changes to Teal Action (#1EAEDB) background with 70% opacity" +- "Build a navigation bar with zero visible background on absolute black, a centered bull logo, 'MENU' text label with hamburger icon on the left, and search + bookmark icons on the right — all in white, sticky position" +- "Create a news card grid on charcoal (#202020) background with white headlines at 27px uppercase, body text in #7D7D7D at 16px, and a white underlined 'Read More' link that turns #3860BE on hover" +- "Design a section divider using a 1px solid bottom border in #202020 on a black canvas — the elevation difference is purely through surface color shift, not shadow" + +### Iteration Guide +When refining existing screens generated with this design system: +1. Focus on ONE component at a time — Lamborghini's system is extreme and every element must feel aggressive +2. Reference specific color names and hex codes from this document — the palette has only about 5 active colors +3. Use natural language descriptions, not CSS values — "sharp-cut golden rectangle" not "border-radius: 0px; background: #FFC000" +4. Describe the desired "feel" alongside specific measurements — "floating in total darkness" communicates the black canvas better than "background: #000000" +5. Remember that UPPERCASE IS THE DEFAULT — if text isn't uppercase at display sizes, it probably should be diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..16f505c --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,88 @@ +# Governance + +This document describes how decisions are made in the agentmemory project. + +The model here is a near-copy of the [Linux Foundation Minimum Viable Governance (MVG)](https://github.com/todogroup/ospolog/blob/main/governance/minimum-viable-governance.md) pattern, scoped to the project's current single-maintainer reality with a concrete plan to diversify maintainership over the next two release cycles. + +## Mission + +Ship a persistent, local-first memory runtime for AI coding agents that: + +- Requires zero external databases. +- Runs under any MCP-compatible client. +- Stays compatible with the open [Model Context Protocol](https://modelcontextprotocol.io). +- Keeps every user's data on the user's machine by default. + +## Roles + +### Users + +Anyone who runs agentmemory. No process obligation beyond the license. Feedback via [GitHub issues](https://github.com/rohitg00/agentmemory/issues) and [discussions](https://github.com/rohitg00/agentmemory/discussions) is the input channel. + +### Contributors + +Anyone who opens an issue, comments on an issue, opens a pull request, or otherwise helps the project. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the how-to. + +### Maintainers + +A Maintainer has commit access to the repository, responsibility for reviewing PRs, and a vote on project-level decisions. The current list is tracked in [MAINTAINERS.md](./MAINTAINERS.md). + +A Maintainer is expected to: + +- Respond to PRs they are review-owner for within a reasonable window (goal: 3 working days for first comment). +- Uphold the [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md). +- Avoid merging their own non-trivial PRs without a second reviewer once the maintainer count is greater than one. +- Disclose conflicts of interest (employer, paid relationships to users). + +### Maintainer acceptance process + +A Contributor becomes a Maintainer by: + +1. Sustained, high-signal contributions over the prior 6 months (multiple merged PRs across more than one subsystem, plus review comments on others' PRs). +2. A Maintainer nominates the Contributor in a public PR editing `MAINTAINERS.md`. +3. The PR stays open for 7 calendar days to collect objections. +4. If no standing objection from an existing Maintainer, the PR merges and the new Maintainer is added. + +A Maintainer steps down by opening a PR that moves their entry to the `Emeritus` section. This is always accepted. + +## Decision-making + +### Default: lazy consensus on PRs + +Most decisions happen inside pull requests. A PR merges when any Maintainer approves it and no other Maintainer blocks it. Silence is assent after 72 hours of no objection. + +### Non-PR decisions + +Anything that is not a normal code change — charter changes, governance edits, maintainer additions/removals, project scope, breaking API changes, relicensing — happens in a GitHub Issue labeled `governance` with a proposal in the first comment. + +- Minor scope decisions: rough consensus in the issue thread, captured by a Maintainer in a summary comment. +- Formal votes: Maintainers react `+1` / `-1` / `0` to the summary comment. Simple majority of Maintainers with a minimum of two distinct voters carries. If only one Maintainer exists, a 7-day public comment window substitutes for a vote. + +### Breaking changes + +A breaking change to the REST / MCP surface requires: + +1. A tracking issue labeled `breaking` opened at least one minor release cycle ahead of the change. +2. A deprecation path in the codebase (warning log, feature flag, or adapter) for at least one minor release. +3. The change landing in the CHANGELOG under a clearly marked `Breaking` sub-section. + +## Release process + +Releases follow [Semantic Versioning](https://semver.org). See the [release process](./CONTRIBUTING.md#release-process) in `CONTRIBUTING.md` and the automated `.github/workflows/publish.yml` pipeline for the mechanics. + +## Conflicts of interest + +Maintainers employed by a company that sells a product competing with agentmemory, or by a company whose business depends on agentmemory's roadmap, should disclose that relationship in `MAINTAINERS.md` next to their name. Nothing prohibits such maintainership; transparency is the requirement. + +## Amending this document + +This document changes by PR. Edits follow the Non-PR decisions path above: open a `governance` issue, collect feedback, then open the PR citing the issue. + +## Related documents + +- [LICENSE](./LICENSE) — Apache-2.0 +- [CONTRIBUTING.md](./CONTRIBUTING.md) — how to contribute +- [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) — community behavior +- [SECURITY.md](./SECURITY.md) — how to report a vulnerability +- [MAINTAINERS.md](./MAINTAINERS.md) — who has commit access +- [ROADMAP.md](./ROADMAP.md) — where the project is heading diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md new file mode 100644 index 0000000..f368220 --- /dev/null +++ b/INSTALL_FOR_AGENTS.md @@ -0,0 +1,164 @@ +# Install agentmemory (agent runbook) + +You are a coding agent. Follow this runbook top to bottom to install agentmemory, wire it into yourself, and verify that persistent memory works. Run each command, check the result against the "Expect" note, then move on. If a step fails, jump to Troubleshooting, and if it still fails, stop and report the step, the exact command, and the error output. + +## What you are installing + +agentmemory is a local memory server for coding agents. It runs on your machine, exposes a REST API on port 3111 and an MCP server, and stores memories on disk under `~/.agentmemory`. It is built on the iii engine, which agentmemory downloads and manages for you as a pinned binary under `~/.agentmemory/bin`. You do not install iii separately. + +Default mode needs no API key and no cloud account. Out of the box it runs hybrid retrieval (BM25 keyword search plus local on-device embeddings), so a full install proves real semantic recall with zero credentials. An LLM provider key is optional and only unlocks richer summaries and auto-injection (see "Optional: richer features"). + +## Prerequisites + +- Node.js >= 20 and npm. Check with `node -v`. +- macOS or Linux for the one-command path. On Windows, use WSL2; native Windows engine setup is manual and `agentmemory connect` is not supported there. +- Ports 3111 (REST), 3112 (streams), 3113 (viewer), and 49134 (engine) free. If any are taken, stop whatever is using them before starting (see Troubleshooting). + +## Running non-interactively + +Several commands prompt on a TTY (for example the first-run "install globally?" question). As an agent you usually want no prompts. Either set `CI=1` in the environment for the commands below, or rely on the fact that agentmemory skips all prompts automatically when stdin/stdout are not a TTY. Prompts are also never-nag: once answered they persist and are not asked again. Re-run onboarding any time with `agentmemory --reset`. + +## 1. Install globally + +```bash +npm install -g @agentmemory/agentmemory +``` + +`npm install -g` already fetches the latest published release. If you hit `EACCES` on a system Node install (macOS/Linux), retry with `sudo npm install -g @agentmemory/agentmemory`. + +No-install alternative: skip this step and run the server with `npx -y @agentmemory/agentmemory@latest` everywhere this runbook says `agentmemory`. The `@latest` and `-y` flags matter because npx caches per version and a bare `npx @agentmemory/agentmemory` can serve a stale release. + +Expect: the install completes without errors. + +## 2. Verify the install + +```bash +agentmemory --version +``` + +Expect: a version string is printed. If `command not found`, the global bin is not on `PATH`; use the `npx -y @agentmemory/agentmemory@latest` form instead. + +## 3. Start the server + +The server listens on port 3111 and auto-starts its pinned iii engine on first run (this can take a few seconds the first time while the engine binary is fetched into `~/.agentmemory/bin`). Run it in the background or in a separate terminal so the rest of the runbook can talk to it. + +```bash +agentmemory & +``` + +Or run `agentmemory` in the foreground in a dedicated terminal. + +Wait until it is reachable, then continue: + +```bash +curl -fsS http://localhost:3111/agentmemory/livez +``` + +Expect: a `200` response. Retry for up to ~15 seconds on first run while the engine warms up. + +## 4. Prove recall in one command + +```bash +agentmemory demo --serve +``` + +`--serve` boots the server, seeds three realistic sessions (JWT auth, an N+1 query fix, rate limiting), runs semantic searches against them, prints the results, and tears everything down. No second terminal needed. + +Expect: the demo finds the "N+1 query fix" memory when it searches for "database performance optimization", which keyword matching alone cannot do. Open `http://localhost:3113` while it runs to watch the memory build live. + +## 5. Wire MCP into the calling agent + +Detect which agent is running this runbook, then wire its MCP config: + +```bash +agentmemory connect +``` + +`connect` merges agentmemory into that agent's MCP config and preserves any existing servers. Supported agent names: + +`claude-code`, `copilot-cli`, `codex`, `cursor`, `gemini-cli`, `opencode`, `cline`, `continue`, `droid`, `hermes`, `openclaw`, `openhuman`, `pi`, `qwen`, `warp`, `zed`, `antigravity`, `kiro`. + +If you cannot tell which agent you are, default to `claude-code`. After wiring, restart the agent or run its MCP reload command (for example `/mcp` in Claude Code) so it picks up the server. + +Expect: the agent now lists agentmemory's tools. With the server running you should see the full set of 53 tools (for example `memory_save`, `memory_smart_search`, `memory_sessions`). If you see only 7 tools, the MCP shim could not reach a server, see Troubleshooting. + +## 6. Install native skills + +```bash +npx skills add rohitg00/agentmemory -y +``` + +This installs the native skills so the agent knows when to call the memory tools, not just that they exist. `connect` makes the tools available; skills teach the agent when to use them. + +Expect: the skills are installed for the detected agent. + +## 7. Verify a save and recall round-trip + +Confirm health first: + +```bash +curl -fsS http://localhost:3111/agentmemory/health +``` + +Expect: a JSON body with an ok status. + +Now write a memory and read it back. If MCP is wired, call the `memory_save` tool followed by `memory_smart_search`. Otherwise use REST directly (note: these are the REST paths, which differ from the MCP tool names): + +```bash +curl -X POST http://localhost:3111/agentmemory/remember \ + -H "Content-Type: application/json" \ + -d '{"content":"agentmemory install verification probe","concepts":["install-check"]}' + +curl -X POST http://localhost:3111/agentmemory/smart-search \ + -H "Content-Type: application/json" \ + -d '{"query":"install verification probe","limit":5}' +``` + +Expect: the first call returns `201`, the second returns `200` with results that include the probe memory you just saved. + +If `AGENTMEMORY_SECRET` is set in the environment, the REST API requires it. Add `-H "Authorization: Bearer $AGENTMEMORY_SECRET"` to both calls. By default no secret is set and localhost is open. + +## Optional: richer features + +These are off by default because they spend tokens. Enable them only if the user wants them. Put configuration in `~/.agentmemory/.env` (no `export` prefix), then restart the server. + +- `AGENTMEMORY_INJECT_CONTEXT=true` makes the SessionStart and PreToolUse hooks inject past memory into the agent's context automatically. Cost: spends session tokens proportional to tool-call frequency. +- `AGENTMEMORY_AUTO_COMPRESS=true` sends each observation to your LLM provider for a richer summary. Cost: spends API tokens proportional to tool-use frequency. Requires a provider key. +- Provider key: set one of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, and similar, in the same file. Without a key, agentmemory stays in zero-LLM mode and still indexes and recalls via BM25 plus local embeddings. + +## Tool surface + +The MCP server exposes 53 tools by default (`--tools all`). Use `--tools core` (or `AGENTMEMORY_TOOLS=core`) for a lean 8-tool set on hosts with tight tool limits. The 8 core tools cover save, recall, consolidate, smart search, sessions, diagnose, lesson save, and reflect. + +## Lifecycle commands + +- `agentmemory status` shows server and engine state. +- `agentmemory doctor` runs diagnostics and reports what is misconfigured. +- `agentmemory stop` stops the engine this CLI started (`stop --force` bypasses the Docker guard). +- `agentmemory upgrade` upgrades agentmemory and the iii runtime, best effort. +- `agentmemory --reset` wipes onboarding preferences and re-runs the wizard. +- `agentmemory import-jsonl ` imports prior Claude Code session logs as memories. + +## Troubleshooting + +- `command not found: agentmemory`: the global bin is not on `PATH`. Use `npx -y @agentmemory/agentmemory@latest`. +- `EACCES` during global install: retry with `sudo`, or use the npx form. +- Stale npx version: run `npx -y @agentmemory/agentmemory@latest`, or clear the cache with `rm -rf ~/.npm/_npx` (macOS/Linux). +- Port already in use: another process holds 3111, 3112, 3113, or 49134. Stop that process, then re-run. +- Server starts but `livez` never returns 200: re-run with `agentmemory --verbose` to see engine stderr. +- Engine version warning on start: harmless. agentmemory uses its own pinned engine in `~/.agentmemory/bin` regardless of any `iii` on `PATH`. Set `AGENTMEMORY_III_VERSION` only to override deliberately. +- "engine conflict" / another iii engine already running: if a different iii version is already serving the port (common if you run your own iii), agentmemory will not adopt it and stops with an "engine conflict" note. Stop that engine (`agentmemory stop --force`, or however you started it), then re-run `agentmemory` — it installs and runs the pinned engine in `~/.agentmemory/bin`, leaving your own iii untouched. +- Only 7 tools visible in the agent: the MCP shim is in local fallback because it could not reach a server. Start `npx @agentmemory/agentmemory` and ensure `AGENTMEMORY_URL` points at it (default `http://localhost:3111`), then reload MCP. +- Windows: use WSL2 for the path above. Native Windows runs the server but `connect` and the automated engine install are not supported. + +## Report success + +Report back to the user: + +- agentmemory installed, version, and the server running on port 3111 +- which agent was wired via `agentmemory connect`, and the tool count the agent now sees +- the save and recall round-trip returned the probe memory +- the viewer is available at `http://localhost:3113` +- whether any optional features were enabled + +If any step failed, report which step, the exact command, and the error output. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb13c72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Rohit Ghumare + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..c6ee2e6 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,21 @@ +# Maintainers + +The authoritative list of people with commit access. See [GOVERNANCE.md](./GOVERNANCE.md) for what a Maintainer is, what they do, and how someone becomes one. + +## Active + +| Name | GitHub | Affiliation | Area of focus | Since | +|-|-|-|-|-| +| Rohit Ghumare | [@rohitg00](https://github.com/rohitg00) | Independent | Project lead, all subsystems | 2026-01 | + +## Emeritus + +_None yet._ + +## Maintainer recruitment + +agentmemory is actively looking to diversify maintainership. The growth plan in [ROADMAP.md](./ROADMAP.md) commits to adding at least one additional Maintainer from a different organization by the end of the current growth cycle. + +If you have a sustained contribution track record and would like to be considered, open an issue tagged `governance`. + +The complete contributor graph, with commit counts and recent activity, lives at . diff --git a/README.md b/README.md new file mode 100644 index 0000000..d04da37 --- /dev/null +++ b/README.md @@ -0,0 +1,1546 @@ +

+ agentmemory — Persistent memory for AI coding agents +

+ +

+ + Your coding agent remembers everything. No more re-explaining. + Built on iii engine +
+ Persistent memory for Claude Code, GitHub Copilot CLI, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode, and any MCP client. +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

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

+ +

+ The gist extends Karpathy's LLM Wiki pattern with confidence scoring, lifecycle, knowledge graphs, and hybrid search: agentmemory is the implementation. +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 1,423+ tests passing +

+ +

+ agentmemory demo +

+ +

+ Install • + Quick Start • + Benchmarks • + vs Competitors • + Agents • + How It Works • + MCP • + Viewer • + iii Console • + Powered by iii • + Config • + API +

+ +--- + +## Install + +Fastest path if you use a coding agent: hand it this one instruction and it installs, wires, and verifies agentmemory end to end. + +> Retrieve and follow the instructions at: https://raw.githubusercontent.com/rohitg00/agentmemory/main/INSTALL_FOR_AGENTS.md + +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. + +```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 +``` + +Or via `npx` (no install): + +```bash +npx @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. + +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. + +Full options at [Quick Start](#quick-start) below. Agent-specific wiring at [Works with every agent](#works-with-every-agent). + +--- + +

Works with every agent

+ +agentmemory works with any agent that supports hooks, MCP, or REST API. All agents share the same memory server. + + + + + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+native plugin + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+native plugin + 6 hooks + MCP +
+GitHub Copilot CLI
+GitHub Copilot CLI
+MCP + plugin hooks/skills +
+OpenClaw
+OpenClaw
+native plugin + MCP +
+Hermes
+Hermes
+native plugin + MCP +
+pi
+pi
+native plugin + MCP +
+OpenHuman
+OpenHuman
+native Memory trait backend +
+Cursor
+Cursor
+MCP server +
+Gemini CLI
+Gemini CLI
+MCP server +
+OpenCode
+OpenCode
+22 hooks + MCP + plugin +
+Cline
+Cline
+MCP server +
+Goose
+Goose
+MCP server +
+Kilo Code
+Kilo Code
+MCP server +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP server +
+Windsurf
+Windsurf
+MCP server +
+Roo Code
+Roo Code
+MCP server +
+Warp
+Warp
+connect + MCP + skills +
+ +

+ Works with any agent that speaks MCP or HTTP. One server, memories shared across all of them. +

+ +--- + +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*. + +```bash +npx @agentmemory/agentmemory +``` + +Latest release notes: [CHANGELOG.md](CHANGELOG.md). + +--- + +

Benchmarks

+ + + + + + +
+ +### Retrieval Accuracy + +**coding-agent-life-v1** (in-house corpus, sandbox-reproducible) + +| Adapter | P@5 | R@5 | Top-5 hit rate | p50 latency | +|---|---|---|---|---| +| **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). + +**LongMemEval-S** (ICLR 2025, 500 questions) + +| System | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| BM25-only fallback | 86.2% | 94.6% | 71.5% | + + + +### Token Savings + +| Approach | Tokens/yr | Cost/yr | +|---|---|---| +| Paste full context | 19.5M+ | Impossible (exceeds window) | +| LLM-summarized | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + local embeddings | ~170K | **$0** | + +
+ +> 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. + +**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/). + +**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). + +--- + +

vs Competitors

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (58K ⭐)Letta / MemGPT (23K ⭐)Khoj (35K ⭐)supermemory (26K ⭐)MemPalace (54K ⭐)oracleagentmemoryHippoBuilt-in (CLAUDE.md)
TypeMemory engine + MCP serverMemory layer APIFull agent runtimePersonal AIMemory API + appVector memory (OSS)Memory engine (Oracle DB)Memory systemStatic file
Retrieval R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/ASelf-reported~96.6% (self-reported)94.4% (self-reported)N/AN/A (grep)
Auto-capture12 hooks (zero manual effort)Manual add() callsAgent self-editsManualAPI-side extractionManualAPI extractionManualManual editing
SearchBM25 + Vector + Graph (RRF fusion)Vector + GraphVector (archival)SemanticVector + RAGVector-onlyVector + semanticDecay-weightedLoads everything into context
Multi-agentMCP + REST + leases + signalsAPI (no coordination)Within Letta runtime onlyNoNoNoScoped onlyMulti-agent sharedPer-agent files
Framework lock-inNone (any MCP client)NoneHigh (must use Letta)StandaloneNoneNoneOracle DatabaseNonePer-agent format
External depsNone (SQLite + iii-engine)Qdrant / pgvectorPostgres + vector DBMultipleManaged cloudVector storeOracle AI DatabaseNoneNone
Memory lifecycle4-tier consolidation + decay + auto-forgetPassive extractionAgent-managedManualAuto-forgetNoneNot statedDecay + consolidationManual pruning
Token efficiency~1,900 tokens/session ($10/yr)Varies by integrationCore memory in contextVariesCloud pricingNo token budgetLLM-backed (varies)Varies22K+ tokens at 240 obs
Real-time viewerYes (port 3113)Cloud dashboardCloud dashboardWeb UICloud dashboardNoNoNoNo
Self-hostedYes (default)OptionalOptionalYesNo (cloud-only)YesYes (Oracle DB)YesYes
+ +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. + +--- + +

Quick Start

+ +Compatibility: this release targets stable `iii-sdk` `^0.11.0` and iii-engine v0.11.x. + +### Try it in 30 seconds + +```bash +# Terminal 1: start the server +npx @agentmemory/agentmemory + +# Terminal 2: seed sample data and see recall in action +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. + +Open `http://localhost:3113` to watch the memory build live. + +### Recommended: install globally + +`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: + +```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 stop # tear it down +agentmemory remove # uninstall everything we created +agentmemory connect claude-code # wire one agent +agentmemory doctor # interactive diagnostics + fix prompts +``` + +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). + +Already have older Claude Code JSONL transcripts you want to bring in? + +```bash +# Import everything under the default ~/.claude/projects +npx @agentmemory/agentmemory import-jsonl + +# Or import a single file +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. + +> **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. + +### Upgrade / Maintenance + +Use the maintenance command when you intentionally want to update your local runtime: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +Warning: this command mutates the current workspace/runtime. It can update JavaScript dependencies and pull the pinned `iiidev/iii:0.11.2` Docker image. It never installs an unpinned or newer iii engine. + +Implementation details live in `src/cli.ts` (see `runUpgrade` around the `src/cli.ts:544-595` region). + +### Claude Code (one block, paste it) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 15 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Claude Code without the plugin install (MCP-standalone path) + +If you wire agentmemory's MCP server through `~/.claude.json` directly instead of using `/plugin install`, Claude Code never resolves `${CLAUDE_PLUGIN_ROOT}` and you have to point hook scripts at absolute paths in `~/.claude/settings.json`. Those paths typically embed the agentmemory version (e.g. `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.22/scripts/…`), so the next upgrade silently breaks every hook. + +Workaround: + +```bash +agentmemory connect claude-code --with-hooks +``` + +This merges the same hook commands into `~/.claude/settings.json` with absolute paths resolved to the bundled `plugin/` directory of the currently installed `@agentmemory/agentmemory` package. Re-run the command after upgrading agentmemory to refresh the paths. User entries in the same file are preserved; only previous agentmemory entries are replaced. Using the `/plugin install` path remains the recommended approach. +For remote or protected deployments, launch Claude Code with `AGENTMEMORY_URL` and `AGENTMEMORY_SECRET` set. The plugin passes both values through to its bundled MCP server; when `AGENTMEMORY_URL` is empty, the MCP shim uses `http://localhost:3111`. + +### Codex CLI (Codex plugin platform) + +```bash +# 1. start the memory server in a separate terminal +npx @agentmemory/agentmemory + +# 2. register the agentmemory marketplace and install the plugin +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +The Codex plugin ships from the same `plugin/` directory as the Claude Code plugin. It registers: + +- `@agentmemory/mcp` as an MCP server (proxies all 53 tools when `AGENTMEMORY_URL` points at a running agentmemory server; falls back to 7 tools locally when no server is reachable) +- 6 lifecycle hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 8 invocable skills: `/recall`, `/remember`, `/session-history`, `/forget`, `/recap`, `/handoff`, `/commit-context`, `/commit-history`, plus 7 reference skills the agent loads on demand (MCP tools, REST API, config, agents, hooks, architecture, and the skill-authoring guide) + +Codex's hook engine injects `CLAUDE_PLUGIN_ROOT` into hook subprocesses (per [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)), so the same hook scripts work across both hosts without duplication. Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure events are Claude-Code-only and are not registered for Codex. + +#### Codex Desktop: plugin hooks currently silent (workaround available) + +`CodexHooks` and `PluginHooks` are both stable + default-enabled in [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs), but Codex Desktop builds currently do not dispatch plugin-local `hooks.json` ([openai/codex#16430](https://github.com/openai/codex/issues/16430)). MCP tools still work; only the lifecycle observations are missing. + +Until upstream lands the fix, mirror the same hook commands into the global `~/.codex/hooks.json`: + +```bash +agentmemory connect codex --with-hooks +``` + +This adds an idempotent block to `~/.codex/hooks.json` referencing absolute paths to the bundled scripts (no `${CLAUDE_PLUGIN_ROOT}` expansion needed at user-scope). Re-run the same command after upgrading agentmemory to refresh paths. User entries in the same file are preserved; only previous agentmemory entries are replaced. + +### GitHub Copilot CLI + +```bash +# MCP-only wiring +agentmemory connect copilot-cli + +# Full hooks/skills plugin from the GitHub subdir +copilot plugin install rohitg00/agentmemory:plugin +``` + +`agentmemory connect copilot-cli` merges `mcpServers.agentmemory` into `~/.copilot/mcp-config.json` (or `$COPILOT_HOME/mcp-config.json` when `COPILOT_HOME` is set) and preserves existing servers. This adapter is Windows-safe even though other `connect` adapters still require manual Windows setup. Copilot picks up the MCP server on next launch or after `/mcp`. Install the plugin as well when you want the full hook/skill experience. + +
+OpenClaw (paste this prompt) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 53 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +Full guide: [`integrations/openclaw/`](integrations/openclaw/) + +
+ +
+Hermes Agent (paste this prompt) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 53 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +Full guide: [`integrations/hermes/`](integrations/hermes/) + +
+ +### Other agents + +Start the memory server: `npx @agentmemory/agentmemory` + +#### Native skills via `npx skills add` (50+ agents) + +agentmemory ships 15 skills in the Claude-Code-style `/SKILL.md` format: 8 invocable action skills (`remember`, `recall`, `recap`, `handoff`, `forget`, `commit-context`, `commit-history`, `session-history`) and 7 reference skills the agent loads on demand (`agentmemory-mcp-tools`, `agentmemory-rest-api`, `agentmemory-config`, `agentmemory-agents`, `agentmemory-hooks`, `agentmemory-architecture`, `write-agentmemory-skill`). The reference skills carry data tables generated from source, so they never drift. The [`skills`](https://npmjs.com/package/skills) CLI by vercel-labs auto-installs them into the calling agent's native skill directory across 50+ agents (Claude Code, Cursor, Cline, Continue, Droid, Warp, Codex, Antigravity, Kiro, OpenCode, Goose, Roo, Trae, Windsurf, and more): + +```bash +npx skills add rohitg00/agentmemory -y # auto-detects the calling agent +npx skills add rohitg00/agentmemory -y -a warp # explicit agent +npx skills add rohitg00/agentmemory -y -a '*' # install to every installed agent +``` + +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. + +#### Standard MCP block + +The agentmemory entry is the **same MCP server block** across every host that uses the `mcpServers` shape (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw): + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**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 | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | Merge into `mcpServers`. One-click deeplink also available on the website. | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | Merge into `mcpServers`. Restart Claude Desktop after editing. | +| **Cline / Roo Code / Kilo Code** | Cline MCP settings (Settings UI → MCP Servers → Edit) | Same `mcpServers` block. | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Same `mcpServers` block. | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (auto-merges). | +| **GitHub Copilot CLI (MCP only)** | `~/.copilot/mcp-config.json` | `agentmemory connect copilot-cli` merges `mcpServers.agentmemory`; Copilot picks it up on next launch or `/mcp`. | +| **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. | +| **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`. | +| **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. | +| **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. | +| **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`. | +| **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`. The `/mcp` slash command inside droid lists configured servers. | +| **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. | + +**Sandboxed MCP clients** (Flatpak / Snap / restrictive containers) that can't reach the host's `localhost`: also set `"AGENTMEMORY_FORCE_PROXY": "1"` in the `env` block, and point `AGENTMEMORY_URL` at a route the sandbox can actually reach (e.g. your LAN IP). + +### 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. + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +Worked example: [`examples/python/`](examples/python/) (quickstart + observation/recall flow). REST on `:3111` remains available for hosts without an iii runtime. + +### From source + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +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. + +- **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` +- **Linux x64:** swap for `x86_64-unknown-linux-gnu` +- **Linux arm64:** swap for `aarch64-unknown-linux-gnu` +- **Windows:** download `iii-x86_64-pc-windows-msvc.zip` from [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2), extract `iii.exe`, add to PATH + +Or use Docker (the bundled `docker-compose.yml` pulls `iiidev/iii:0.11.2`). Full docs: [iii.dev/docs](https://iii.dev/docs). + +### 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: + +**Option A — Prebuilt Windows binary (recommended):** + +```powershell +# 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 in your browser +# (we pin to v0.11.2 until agentmemory refactors for the new sandbox +# model that engine v0.11.6+ requires) +# 2. Download iii-x86_64-pc-windows-msvc.zip +# (or iii-aarch64-pc-windows-msvc.zip if you're on an ARM machine) +# 3. Extract iii.exe somewhere on PATH, or place it at: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory checks that location automatically) +# 4. Verify: +iii --version +# Should print: 0.11.2 + +# 5. Then run agentmemory as usual: +npx -y @agentmemory/agentmemory +``` + +**Option B — Docker Desktop:** + +```powershell +# 1. Install Docker Desktop for Windows +# 2. Start Docker Desktop and make sure the engine is running +# 3. Run agentmemory — it will auto-start the bundled compose file: +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: + +```powershell +npx -y @agentmemory/agentmemory mcp +# or via the shim package: +npx -y @agentmemory/mcp +``` + +**Diagnostics for Windows:** if `npx @agentmemory/agentmemory` fails, re-run with `--verbose` to see the actual engine stderr. Common failure modes: + +| Symptom | Fix | +|---|---| +| `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. + +--- + +

Deploy

+ +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 +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 +the HMAC secret, then drops privileges from `root` to `node` via +`gosu` before exec'ing the agentmemory CLI. + +

+ Deploy to fly.io + Deploy to Railway +

+ +Render's one-click deploy button requires `render.yaml` at the repository root, which we deliberately keep clean. Use the Render Blueprint flow documented in [`deploy/render/`](./deploy/render/README.md) to point at the in-repo blueprint manually. + +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 + `auto_stop_machines = "stop"`; cheapest idle. +- [`deploy/railway`](./deploy/railway/README.md) — Hobby plan flat fee, + volume in the dashboard. +- [`deploy/render`](./deploy/render/README.md) — Blueprint flow, + automatic disk snapshots on paid plans. +- [`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 +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. + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### 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. + +| | Built-in (CLAUDE.md) | agentmemory | +|---|---|---| +| Scale | 200-line cap | Unlimited | +| Search | Loads everything into context | BM25 + vector + graph (top-K only) | +| Token cost | 22K+ at 240 observations | ~1,900 tokens (92% less) | +| Cross-agent | Per-agent files | MCP + REST (any agent) | +| Coordination | None | Leases, signals, actions, routines | +| Observability | Read files manually | Real-time viewer on :3113 | + +--- + +

How It Works

+ +### Memory Pipeline + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4-Tier Memory Consolidation + +Inspired by how human brains process memory — not unlike sleep consolidation. + +| Tier | What | Analogy | +|------|------|---------| +| **Working** | Raw observations from tool use | Short-term memory | +| **Episodic** | Compressed session summaries | "What happened" | +| **Semantic** | Extracted facts and patterns | "What I know" | +| **Procedural** | Workflows and decision patterns | "How to do it" | + +Memories decay over time (Ebbinghaus curve). Frequently accessed memories strengthen. Stale memories auto-evict. Contradictions are detected and resolved. + +### What Gets Captured + +| Hook | Captures | +|------|----------| +| `SessionStart` | Project path, session ID | +| `UserPromptSubmit` | User prompts (privacy-filtered) | +| `PreToolUse` | File access patterns + enriched context | +| `PostToolUse` | Tool name, input, output | +| `PostToolUseFailure` | Error context | +| `PreCompact` | Re-injects memory before compaction | +| `SubagentStart/Stop` | Sub-agent lifecycle | +| `Stop` | End-of-session summary | +| `SessionEnd` | Session complete marker | + +### Key Capabilities + +| Capability | Description | +|---|---| +| **Automatic capture** | Every tool use recorded via hooks — zero manual effort | +| **Semantic search** | BM25 + vector + knowledge graph with RRF fusion | +| **Memory evolution** | Versioning, supersession, relationship graphs | +| **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 | +| **Claude bridge** | Bi-directional sync with MEMORY.md | +| **Knowledge graph** | Entity extraction + BFS traversal | +| **Team memory** | Namespaced shared + private across team members | +| **Citation provenance** | Trace any memory back to source observations | +| **Git snapshots** | Version, rollback, and diff memory state | + +--- + + + +Triple-stream retrieval combining three signals: + +| Stream | What it does | When | +|---|---|---| +| **BM25** | Stemmed keyword matching with synonym expansion | Always on | +| **Vector** | Cosine similarity over dense embeddings | Embedding provider configured | +| **Graph** | Knowledge graph traversal via entity matching | Entities detected in query | + +Fused with Reciprocal Rank Fusion (RRF, k=60) and session-diversified (max 3 results per session). + +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 + +agentmemory auto-detects your provider. For best results, install local embeddings (free): + +```bash +npm install @xenova/transformers +``` + +| Provider | Model | Cost | Notes | +|---|---|---|---| +| **Local (recommended)** | `all-MiniLM-L6-v2` | Free | Offline, +8pp recall over BM25-only | +| Gemini | `gemini-embedding-001` | Free tier | 100+ languages, 768/1536/3072 dims (MRL), 2048-token input. Replaces `text-embedding-004` ([deprecated, shutdown Jan 14, 2026](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | $0.02/1M | Highest quality | +| Voyage AI | `voyage-code-3` | Paid | Optimized for code | +| Cohere | `embed-english-v3.0` | Free trial | General purpose | +| OpenRouter | Any model | Varies | Multi-model proxy | + +--- + +

MCP Server

+ +53 tools, 6 resources, 3 prompts, and 15 skills, the most comprehensive MCP memory toolkit for any agent. + +> **MCP shim vs full server:** the published `@agentmemory/mcp` package is a thin shim. It exposes the full 53-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`. + +### 53 Tools + +
+Core tools (always available) + +| 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_sessions` | List recent sessions | +| `memory_timeline` | Chronological observations | +| `memory_profile` | Project profile (concepts, files, patterns) | +| `memory_export` | Export all memory data | +| `memory_relations` | Query relationship graph | + +
+ +
+Extended tools (53 total — set AGENTMEMORY_TOOLS=all) + +| Tool | Description | +|------|-------------| +| `memory_patterns` | Detect recurring patterns | +| `memory_timeline` | Chronological observations | +| `memory_relations` | Query relationship graph | +| `memory_graph_query` | Knowledge graph traversal | +| `memory_consolidate` | Run 4-tier consolidation | +| `memory_claude_bridge_sync` | Sync with MEMORY.md | +| `memory_team_share` | Share with team members | +| `memory_team_feed` | Recent shared items | +| `memory_audit` | Audit trail of operations | +| `memory_governance_delete` | Delete with audit trail | +| `memory_snapshot_create` | Git-versioned snapshot | +| `memory_action_create` | Create work items with dependencies | +| `memory_action_update` | Update action status | +| `memory_frontier` | Unblocked actions ranked by priority | +| `memory_next` | Single most important next action | +| `memory_lease` | Exclusive action leases (multi-agent) | +| `memory_routine_run` | Instantiate workflow routines | +| `memory_signal_send` | Inter-agent messaging | +| `memory_signal_read` | Read messages with receipts | +| `memory_checkpoint` | External condition gates | +| `memory_mesh_sync` | P2P sync between instances | +| `memory_sentinel_create` | Event-driven watchers | +| `memory_sentinel_trigger` | Fire sentinels externally | +| `memory_sketch_create` | Ephemeral action graphs | +| `memory_sketch_promote` | Promote to permanent | +| `memory_crystallize` | Compact action chains | +| `memory_diagnose` | Health checks | +| `memory_heal` | Auto-fix stuck state | +| `memory_facet_tag` | Dimension:value tags | +| `memory_facet_query` | Query by facet tags | +| `memory_verify` | Trace provenance | + +
+ +### 6 Resources · 3 Prompts · 4 Skills + +| Type | Name | Description | +|------|------|-------------| +| Resource | `agentmemory://status` | Health, session count, memory count | +| Resource | `agentmemory://project/{name}/profile` | Per-project intelligence | +| Resource | `agentmemory://memories/latest` | Latest 10 active memories | +| Resource | `agentmemory://graph/stats` | Knowledge graph statistics | +| Prompt | `recall_context` | Search + return context messages | +| Prompt | `session_handoff` | Handoff data between agents | +| Prompt | `detect_patterns` | Analyze recurring patterns | +| Skill | `/recall` | Search memory | +| Skill | `/remember` | Save to long-term memory | +| Skill | `/session-history` | Recent session summaries | +| Skill | `/forget` | Delete observations/sessions | + +### Standalone MCP + +Run without the full server — for any MCP client. Either of these works: + +```bash +npx -y @agentmemory/agentmemory mcp # canonical (always available) +npx -y @agentmemory/mcp # shim package alias +``` + +Or add to your agent's MCP config: + +Most agents (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +Merge the `agentmemory` entry into your host's existing `mcpServers` object rather than replacing the file. For sandboxed clients that can't reach the host's `localhost`, add `"AGENTMEMORY_FORCE_PROXY": "1"` to the env block and set `AGENTMEMORY_URL` to a route the sandbox can reach. + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Copy the plugin file from the repo: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +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. + +```bash +open http://localhost:3113 +``` + +The viewer server binds to `127.0.0.1` by default. The REST-served `/agentmemory/viewer` endpoint follows the normal `AGENTMEMORY_SECRET` bearer-token rules. CSP headers use a per-response script nonce and disable inline handler attributes (`script-src-attr 'none'`). + +--- + +

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

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

+ +**Already installed.** The console ships with `iii` — no separate installer. + +**Launch alongside agentmemory:** + +```bash +# agentmemory viewer holds port 3113, so run the console on 3114. +# Engine REST (3111), WebSocket (3112), and bridge (49134) defaults match agentmemory. +iii console --port 3114 +``` + +Then open `http://localhost:3114`. Add `--enable-flow` for the experimental architecture-graph page. + +Override engine endpoints only if you've moved them: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**What you can do from the console:** + +| 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. | +| **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. | +| **Flow** | (Optional, `--enable-flow`) Interactive architecture graph of every worker, trigger, and stream. | + +

+ iii console trace waterfall view showing per-span duration +
+ Traces: waterfall / flame / service breakdown for every memory operation. +

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

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. + +That means one more command extends agentmemory with an entire new capability. + +### Extend agentmemory with one command + +```bash +iii worker add iii-pubsub # fan memory writes out to every connected instance +iii worker add iii-cron # scheduled consolidation, decay sweeps, snapshot rotation +iii worker add iii-queue # durable retries for embedding + compression jobs +iii worker add iii-observability # OTEL traces on every memory op (default on) +iii worker add iii-sandbox # run recalled code inside an isolated microVM +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. + +| `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-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-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. + +### What iii replaces + +| Traditional stack | agentmemory uses | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + in-memory vector index | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | iii engine worker supervision | +| Prometheus / Grafana | iii OTEL + health monitor | +| Custom plugin systems | `iii worker add ` | + +**174 source files · ~37,800 LOC · 1,423+ tests · 258 functions · 44 KV scopes** — all on three primitives. No `agentmemory plugin install`. The plugin system is iii itself. + +--- + +

Configuration

+ +### LLM Providers + +agentmemory auto-detects from your environment. By default, no LLM calls are made unless you configure a provider or explicitly opt in to the Claude subscription fallback. + +| Provider | Config | Notes | +|----------|--------|-------| +| **No-op (default)** | No config needed | LLM-backed compress/summarize is DISABLED. Synthetic BM25 compression + recall still work. See `AGENTMEMORY_ALLOW_AGENT_SDK` below if you used to rely on the Claude-subscription fallback. | +| Anthropic API | `ANTHROPIC_API_KEY` | Per-token billing | +| 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` | +| **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. | + +### 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. + +**Ollama** (default port `11434`): + +```bash +ollama pull qwen2.5-coder:7b # or llama3.2:3b, mistral:7b, etc. +ollama serve +``` + +```env +# ~/.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 +``` + +**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.). + +```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 +``` + +**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. + +**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 | + +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 `@xenova/transformers` — `EMBEDDING_PROVIDER=local` (default) gives you BGE-small entirely on-device. No extra config needed. + +### Cost-aware model selection + +Background compression runs on every observation, so model choice meaningfully changes monthly spend. Captured workload data: 635 requests / 888K tokens / 35 hours of active use, run against three OpenRouter models at 2026-05-23 pricing. + +| Tier | Model | Input / 1M | Output / 1M | Cost for the captured 35h | Notes | +|------|-------|------------|-------------|---------------------------|-------| +| 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. | + +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. + +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/). + +### Multi-agent memory (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +In multi-agent setups where several roles share one agentmemory server (architect / developer / reviewer / researcher / support-agent), `AGENT_ID` tags every write with the role that made it. `AGENTMEMORY_AGENT_SCOPE` controls whether recall filters by that tag. + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" +``` + +Two modes: + +| Mode | Tag writes | Filter recall | When to use | +|------|------------|---------------|-------------| +| `shared` (default) | yes | no | Cross-agent context with audit trail. Architect can see what developer noted, but every row records who said it. | +| `isolated` | yes | yes | Strict separation. Architect never sees developer's observations / memories / sessions. | + +What gets tagged when `AGENT_ID` is set: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. The role flows from `api::session::start` → `mem::observe` → `mem::compress` → KV. + +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. + +When `AGENT_ID` is unset, memory remains unscoped (legacy behavior, no tags, no filters). + +### Ports + +agentmemory + iii-engine bind four ports by default. If a restart fails with `port in use`, this table tells you which process to look for. + +| Port | Process | Purpose | Env override | +|------|---------|---------|--------------| +| `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`) | + +Stale-process cleanup when ports stay bound after a crashed run: + +```bash +# macOS / Linux — find whatever is on each port and kill it +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +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. + +### Config File + +Put agentmemory runtime configuration in `~/.agentmemory/.env` instead of exporting variables in every shell. If the viewer shows a setup hint like `export ANTHROPIC_API_KEY=...`, copy it into this file as `ANTHROPIC_API_KEY=...` without the `export` prefix, then restart agentmemory. + +Process environment variables still work and take precedence over values in the file. + +On Windows, the same file lives at `%USERPROFILE%\.agentmemory\.env`: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +To test with a Claude Code Pro/Max subscription instead of an API key, opt in explicitly: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +Consolidation (graph nodes, lessons, crystals) is on by default whenever an LLM provider is configured. Explicitly opt out with `CONSOLIDATION_ENABLED=false` if you want LLM-free operation. Graph extraction is a separate flag: + +```env +GRAPH_EXTRACTION_ENABLED=true +# CONSOLIDATION_ENABLED=false # opt out of auto-consolidation +``` + +### Environment Variables + +Create `~/.agentmemory/.env`: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=false # on by default when an LLM provider is configured +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools, lean fallback) or "all" (53 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +128 endpoints on port `3111`. The REST API binds to `127.0.0.1` by default. Protected endpoints require `Authorization: Bearer ` when `AGENTMEMORY_SECRET` is set, and mesh sync endpoints require `AGENTMEMORY_SECRET` on both peers. + +
+Key endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | Health check (always public) | +| `POST` | `/agentmemory/session/start` | Start session + get context | +| `POST` | `/agentmemory/session/end` | End session | +| `POST` | `/agentmemory/observe` | Capture observation | +| `POST` | `/agentmemory/smart-search` | Hybrid search | +| `POST` | `/agentmemory/context` | Generate context | +| `POST` | `/agentmemory/remember` | Save to long-term memory | +| `POST` | `/agentmemory/forget` | Delete observations | +| `POST` | `/agentmemory/enrich` | File context + memories + bugs | +| `GET` | `/agentmemory/profile` | Project profile | +| `GET` | `/agentmemory/export` | Export all data | +| `POST` | `/agentmemory/import` | Import from JSON | +| `POST` | `/agentmemory/graph/query` | Knowledge graph query | +| `POST` | `/agentmemory/team/share` | Share with team | +| `GET` | `/agentmemory/audit` | Audit trail | + +Full endpoint list: [`src/triggers/api.ts`](src/triggers/api.ts) + +
+ +--- + +

Development

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 1,423+ tests +npm run test:integration # API tests (requires running services) +``` + +**Prerequisites:** Node.js >= 20, [iii-engine](https://iii.dev/docs) or Docker + +

License

+ +[Apache-2.0](LICENSE) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..db32ca8 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`rohitg00/agentmemory` +- 原始仓库:https://github.com/rohitg00/agentmemory +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/READMEs/README.de-DE.md b/READMEs/README.de-DE.md new file mode 100644 index 0000000..8d92f8a --- /dev/null +++ b/READMEs/README.de-DE.md @@ -0,0 +1,1376 @@ +

+ agentmemory — Persistentes Gedächtnis für KI-Coding-Agenten +

+ +

+ + Ihr Coding-Agent merkt sich alles. Schluss mit dem ständigen Wiederholen. + Built on iii engine +
+ Persistentes Gedächtnis für Claude Code, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode und jeden MCP-Client. +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Design-Dokument: 1200 stars / 172 forks im Gist +

+ +

+ Das Gist erweitert Karpathys LLM-Wiki-Muster um Confidence Scoring, Lifecycle, Knowledge Graphs und hybride Suche: agentmemory ist die Implementierung. +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ agentmemory-Demo +

+ +

+ Installation • + Schnellstart • + Benchmarks • + Vergleich • + Agenten • + Funktionsweise • + MCP • + Viewer • + iii Console • + Powered by iii • + Konfiguration • + API +

+ +--- + +## Install + +```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 connect claude-code # wire your agent (also: codex, cursor, gemini-cli, ...) +``` + +Oder per `npx` (keine Installation): + +```bash +npx @agentmemory/agentmemory +``` + +Achtung — npx cached pro Version. Wenn ein nacktes `npx @agentmemory/agentmemory` eine ältere Version liefert, erzwingen Sie die neueste mit `npx -y @agentmemory/agentmemory@latest` oder leeren Sie den Cache einmalig mit `rm -rf ~/.npm/_npx` (macOS/Linux; unter Windows löschen Sie `%LOCALAPPDATA%\npm-cache\_npx`). Der erste npx-Lauf ab v0.9.16+ fordert eine globale Installation inline an, sodass der nackte Befehl `agentmemory` anschließend überall funktioniert. + +Vollständige Optionen unter [Schnellstart](#quick-start). Agenten­spezifische Verdrahtung unter [Funktioniert mit jedem Agenten](#works-with-every-agent). + +--- + +

Funktioniert mit jedem Agenten

+ +agentmemory funktioniert mit jedem Agenten, der Hooks, MCP oder REST API unterstützt. Alle Agenten teilen sich denselben Memory-Server. + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+natives Plugin + 12 Hooks + MCP +
+Codex CLI
+Codex CLI
+natives Plugin + 6 Hooks + MCP +
+OpenClaw
+OpenClaw
+natives Plugin + MCP +
+Hermes
+Hermes
+natives Plugin + MCP +
+pi
+pi
+natives Plugin + MCP +
+OpenHuman
+OpenHuman
+natives Memory-trait-Backend +
+Cursor
+Cursor
+MCP-Server +
+Gemini CLI
+Gemini CLI
+MCP-Server +
+OpenCode
+OpenCode
+22 Hooks + MCP + Plugin +
+Cline
+Cline
+MCP-Server +
+Goose
+Goose
+MCP-Server +
+Kilo Code
+Kilo Code
+MCP-Server +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP-Server +
+Windsurf
+Windsurf
+MCP-Server +
+Roo Code
+Roo Code
+MCP-Server +
+ +

+ Funktioniert mit jedem Agenten, der MCP oder HTTP spricht. Ein Server, gemeinsame Erinnerungen für alle. +

+ +--- + +Sie erklären in jeder Session dieselbe Architektur. Sie entdecken dieselben Bugs erneut. Sie bringen dem Agenten dieselben Präferenzen wieder bei. Eingebautes Gedächtnis (CLAUDE.md, .cursorrules) ist bei 200 Zeilen am Ende und veraltet. agentmemory behebt das. Es erfasst stillschweigend, was Ihr Agent tut, komprimiert das Ganze in durchsuchbares Gedächtnis und injiziert beim Start der nächsten Session den passenden Kontext. Ein Befehl. Funktioniert über Agenten hinweg. + +**Was sich ändert:** Session 1 richten Sie JWT-Authentifizierung ein. Session 2 fragen Sie nach Rate Limiting. Der Agent weiß bereits, dass Ihre Auth jose-Middleware in `src/middleware/auth.ts` verwendet, dass Ihre Tests Token-Validierung abdecken und dass Sie sich aus Gründen der Edge-Kompatibilität für jose statt jsonwebtoken entschieden haben. Kein Wiederholen. Kein Copy-Paste. Der Agent *weiß es einfach*. + +```bash +npx @agentmemory/agentmemory +``` + +> **Neu in v0.9.0** — Landing-Site unter [agent-memory.dev](https://agent-memory.dev), Filesystem-Connector (`@agentmemory/fs-watcher`), das standalone MCP proxyt nun zum laufenden Server, sodass Hooks und Viewer übereinstimmen, Audit-Policy auf jedem Delete-Pfad kodifiziert, der Health-Check meldet `memory_critical` nicht mehr bei kleinen Node-Prozessen. Vollständige Hinweise in [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18). + +--- + +

Benchmarks

+ + + + + + +
+ +### Retrieval-Genauigkeit + +**coding-agent-life-v1** (interner Korpus, Sandbox-reproduzierbar) + +| Adapter | P@5 | R@5 | Top-5-Trefferquote | p50-Latenz | +|---|---|---|---|---| +| **agentmemory hybrid** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep-Baseline | 0.267 | 0.967 | 15 / 15 | 0 ms | + +100 % Top-5-Trefferquote. **2,2×** bessere Präzision als die grep-Baseline bei identischer Eingabe. Volle Aufschlüsselung pro Typ: [`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 Fragen) + +| System | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| BM25-only Fallback | 86.2% | 94.6% | 71.5% | + + + +### Token-Einsparungen + +| Ansatz | Tokens/Jahr | Kosten/Jahr | +|---|---|---| +| Vollständigen Kontext einfügen | 19,5M+ | Unmöglich (überschreitet das Fenster) | +| LLM-zusammengefasst | ~650K | ~500 $ | +| **agentmemory** | **~170K** | **~10 $** | +| agentmemory + lokale Embeddings | ~170K | **0 $** | + +
+ +> Embedding-Modell: `all-MiniLM-L6-v2` (lokal, kostenlos, kein API-Schlüssel). Vollständige Berichte: [`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](../benchmark/QUALITY.md), [`benchmark/SCALE.md`](../benchmark/SCALE.md). Konkurrenzvergleich: [`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory vs mem0, Letta, Khoj, claude-mem, Hippo. + +**Lokal reproduzieren:** [`eval/README.md`](../eval/README.md) — Adapter-pluggable Harness für LongMemEval `_s` (öffentlich, 500 Fragen) + `coding-agent-life-v1` (interner 15-Session-Korpus). Adapter für grep / vector / agentmemory werden direkt verglichen, NDJSON-Ausgabe, veröffentlichte Scorecards landen in [`docs/benchmarks/`](../docs/benchmarks/). + +**Funktioniert kombiniert mit [codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything) und [Graphify](https://github.com/safishamsi/graphify).** Code-Graph-Indizierung, mehragentige Build-Pipelines und breitere Knowledge Graphs über Docs / PDFs / Bilder / Videos. agentmemory merkt sich die Arbeit; diese drei Projekte beleuchten den Rest der Kontextschicht. Rezepte + Frage-Routing-Tabelle: [`docs/recipes/pairings.md`](../docs/recipes/pairings.md). + +--- + +

Vergleich mit der Konkurrenz

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)Eingebaut (CLAUDE.md)
TypMemory-Engine + MCP-ServerMemory-Layer-APIKomplette Agenten-RuntimeStatische Datei
Retrieval R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/V (grep)
Auto-Erfassung12 Hooks (null manueller Aufwand)Manuelle add()-AufrufeAgent bearbeitet sich selbstManuelle Bearbeitung
SucheBM25 + Vector + Graph (RRF-Fusion)Vector + GraphVector (Archival)Lädt alles in den Kontext
Multi-AgentMCP + REST + Leases + SignalsAPI (keine Koordination)Nur innerhalb der Letta-RuntimeDateien pro Agent
Framework-Lock-inKeiner (jeder MCP-Client)KeinerHoch (Letta erforderlich)Format pro Agent
Externe AbhängigkeitenKeine (SQLite + iii-engine)Qdrant / pgvectorPostgres + Vector-DBKeine
Memory-Lifecycle4-stufige Konsolidierung + Decay + Auto-ForgetPassive ExtraktionVom Agenten verwaltetManuelles Pruning
Token-Effizienz~1.900 Tokens/Session (10 $/Jahr)Je nach Integration unterschiedlichCore Memory im Kontext22K+ Tokens bei 240 Beobachtungen
Echtzeit-ViewerJa (Port 3113)Cloud-DashboardCloud-DashboardNein
Self-hostedJa (Standard)OptionalOptionalJa
+ +--- + +

Schnellstart

+ +Kompatibilität: Diese Version zielt auf stabiles `iii-sdk` `^0.11.0` und iii-engine v0.11.x ab. + +### In 30 Sekunden ausprobieren + +```bash +# Terminal 1: start the server +npx @agentmemory/agentmemory + +# Terminal 2: seed sample data and see recall in action +npx @agentmemory/agentmemory demo +``` + +`demo` befüllt 3 realistische Sessions (JWT-Auth, N+1-Query-Fix, Rate Limiting) und führt semantische Suchen darauf aus. Sie sehen, wie „N+1 query fix" gefunden wird, wenn Sie nach „database performance optimization" suchen — Keyword-Matching kann das nicht. + +Öffnen Sie `http://localhost:3113`, um das Memory in Echtzeit aufgebaut zu sehen. + +### Empfohlen: global installieren + +`npx` cached pro Version. Wenn Sie letzte Woche `npx @agentmemory/agentmemory@0.9.14` ausgeführt haben, kann ein nacktes `npx @agentmemory/agentmemory` das veraltete 0.9.14 aus `~/.npm/_npx/` ausliefern und nicht die neueste Version. Einmal installieren, und der nackte Befehl `agentmemory` funktioniert überall: + +```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 stop # tear it down +agentmemory remove # uninstall everything we created +agentmemory connect claude-code # wire one agent +agentmemory doctor # interactive diagnostics + fix prompts +``` + +Ab v0.9.16 fordert der erste npx-Lauf inline zu einer globalen Installation auf — einmal mit `Y` antworten, fertig. Wenn Sie das überspringen, greifen Sie für einen frischen Fetch auf eine dieser Möglichkeiten zurück: + +```bash +npx -y @agentmemory/agentmemory@latest # forces latest from npm (cross-platform) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # macOS/Linux only (POSIX shell) +``` + +Unter Windows / PowerShell lautet das Äquivalent zum Leeren des Caches `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` — die plattformübergreifende Option ist `npx -y ...@latest` oben. + +### Session-Replay + +Jede Session, die agentmemory aufzeichnet, ist abspielbar. Öffnen Sie den Viewer, wählen Sie den Reiter **Replay** und scrubben Sie durch die Timeline: Prompts, Tool-Aufrufe, Tool-Ergebnisse und Antworten werden als diskrete Events mit Play/Pause, Geschwindigkeitssteuerung (0,5×–4×) und Tastenkürzeln (Leertaste zum Umschalten, Pfeile zum Schrittweisen) gerendert. + +Haben Sie ältere Claude-Code-JSONL-Transkripte, die Sie übernehmen wollen? + +```bash +# Import everything under the default ~/.claude/projects +npx @agentmemory/agentmemory import-jsonl + +# Or import a single file +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +Importierte Sessions tauchen im Replay-Picker neben den nativen auf. Intern routet jeder Eintrag durch die iii-Funktionen `mem::replay::load`, `mem::replay::sessions` und `mem::replay::import-jsonl` — keine Seitenkanal-Server. + +### Upgrade / Wartung + +Verwenden Sie den Wartungsbefehl, wenn Sie Ihr lokales Runtime bewusst aktualisieren wollen: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +Achtung: Dieser Befehl verändert den aktuellen Workspace/Runtime. Er kann JavaScript-Abhängigkeiten aktualisieren und das gepinnte Docker-Image `iiidev/iii:0.11.2` ziehen. Er installiert niemals eine ungepinnte oder neuere iii-Engine. + +Implementierungsdetails in `src/cli.ts` (siehe `runUpgrade` rund um den Bereich `src/cli.ts:544-595`). + +### Claude Code (ein Block, einfügen) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Claude Code ohne Plugin-Installation (MCP-Standalone-Pfad) + +Wenn Sie den MCP-Server von agentmemory direkt über `~/.claude.json` verdrahten anstatt über `/plugin install`, löst Claude Code `${CLAUDE_PLUGIN_ROOT}` niemals auf, und Sie müssen Hook-Skripte in `~/.claude/settings.json` auf absolute Pfade zeigen lassen. Diese Pfade enthalten typischerweise die agentmemory-Version (z. B. `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), sodass das nächste Upgrade jeden Hook stillschweigend bricht. + +Workaround: + +```bash +agentmemory connect claude-code --with-hooks +``` + +Das mischt dieselben Hook-Befehle in `~/.claude/settings.json` ein, mit absoluten Pfaden, die in das mitgelieferte `plugin/`-Verzeichnis des aktuell installierten `@agentmemory/agentmemory`-Pakets auflösen. Führen Sie den Befehl nach einem agentmemory-Upgrade erneut aus, um die Pfade zu aktualisieren. Eigene Einträge in derselben Datei bleiben erhalten; nur frühere agentmemory-Einträge werden ersetzt. Den `/plugin install`-Pfad zu nutzen, bleibt der empfohlene Ansatz. +Für entfernte oder geschützte Deployments starten Sie Claude Code mit gesetztem `AGENTMEMORY_URL` und `AGENTMEMORY_SECRET`. Das Plugin reicht beide Werte an seinen mitgelieferten MCP-Server weiter; ist `AGENTMEMORY_URL` leer, verwendet das MCP-Shim `http://localhost:3111`. + +### Codex CLI (Codex-Plugin-Plattform) + +```bash +# 1. start the memory server in a separate terminal +npx @agentmemory/agentmemory + +# 2. register the agentmemory marketplace and install the plugin +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Das Codex-Plugin wird aus demselben `plugin/`-Verzeichnis ausgeliefert wie das Claude-Code-Plugin. Es registriert: + +- `@agentmemory/mcp` als MCP-Server (proxyt alle 51 Tools, wenn `AGENTMEMORY_URL` auf einen laufenden agentmemory-Server zeigt; fällt lokal auf 7 Tools zurück, wenn kein Server erreichbar ist) +- 6 Lifecycle-Hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 4 Skills: `/recall`, `/remember`, `/session-history`, `/forget` + +Codex' Hook-Engine injiziert `CLAUDE_PLUGIN_ROOT` in Hook-Subprozesse (siehe [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)), sodass dieselben Hook-Skripte ohne Duplikation auf beiden Hosts laufen. Die Events Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure gibt es nur in Claude Code und werden für Codex nicht registriert. + +#### Codex Desktop: Plugin-Hooks derzeit lautlos (Workaround vorhanden) + +`CodexHooks` und `PluginHooks` sind beide stabil und standardmäßig aktiviert in [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs), aber aktuelle Codex-Desktop-Builds dispatchen die plugin-lokale `hooks.json` nicht ([openai/codex#16430](https://github.com/openai/codex/issues/16430)). MCP-Tools funktionieren weiterhin; nur die Lifecycle-Beobachtungen fehlen. + +Solange der Fix upstream nicht gelandet ist, spiegeln Sie dieselben Hook-Befehle in die globale `~/.codex/hooks.json`: + +```bash +agentmemory connect codex --with-hooks +``` + +Das fügt einen idempotenten Block zu `~/.codex/hooks.json` hinzu, der absolute Pfade zu den mitgelieferten Skripten referenziert (keine `${CLAUDE_PLUGIN_ROOT}`-Expansion auf Benutzer-Scope nötig). Führen Sie denselben Befehl nach einem agentmemory-Upgrade erneut aus, um die Pfade zu aktualisieren. Eigene Einträge in derselben Datei bleiben erhalten; nur frühere agentmemory-Einträge werden ersetzt. + +
+OpenClaw (diesen Prompt einfügen) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +Vollständiger Leitfaden: [`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent (diesen Prompt einfügen) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +Vollständiger Leitfaden: [`integrations/hermes/`](../integrations/hermes/) + +
+ +### Andere Agenten + +Starten Sie den Memory-Server: `npx @agentmemory/agentmemory` + +Der agentmemory-Eintrag ist der **gleiche MCP-Server-Block** für jeden Host, der das `mcpServers`-Format verwendet (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw): + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**Fügen Sie diesen Eintrag in das bestehende `mcpServers`-Objekt** in der Konfigurationsdatei des Hosts ein — ersetzen Sie nicht die Datei. Wenn die Datei bereits andere Server enthält, fügen Sie `agentmemory` als zusätzlichen Schlüssel innerhalb von `mcpServers` daneben ein. Fehlt `mcpServers` ganz, fügen Sie den Block innerhalb von `{ "mcpServers": { ... } }` ein. Die `${VAR}`-Platzhalter übernehmen `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` aus der Shell beim Start des MCP-Servers — nicht gesetzte Variablen werden als leere Strings übergeben, und das Shim fällt auf `http://localhost:3111` zurück. Ein einziger verdrahteter Eintrag deckt sowohl lokale als auch entfernte (k8s / reverse-proxied) Deployments ab. + +| Agent | Konfigurationsdatei | Hinweise | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | In `mcpServers` einfügen. Ein-Klick-Deeplink auch auf der Website. | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | In `mcpServers` einfügen. Claude Desktop nach dem Editieren neu starten. | +| **Cline / Roo Code / Kilo Code** | Cline-MCP-Einstellungen (Settings UI → MCP Servers → Edit) | Gleicher `mcpServers`-Block. | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Gleicher `mcpServers`-Block. | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (automatisches Mergen). | +| **OpenClaw** | OpenClaw-MCP-Konfig | Gleicher `mcpServers`-Block oder das tiefer integrierte [Memory-Plugin](../integrations/openclaw/). | +| **Codex CLI (nur MCP)** | `.codex/config.toml` | TOML-Form: `codex mcp add agentmemory -- npx -y @agentmemory/mcp` oder `[mcp_servers.agentmemory]` manuell hinzufügen. | +| **Codex CLI (volles Plugin)** | Codex-Plugin-Marketplace | `codex plugin marketplace add rohitg00/agentmemory`, dann `codex plugin add agentmemory@agentmemory`. Registriert MCP + 6 Lifecycle-Hooks (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 Skills. Auf Codex Desktop zusätzlich `agentmemory connect codex --with-hooks` ausführen, bis [openai/codex#16430](https://github.com/openai/codex/issues/16430) landet — Plugin-Hooks sind dort derzeit lautlos. | +| **OpenCode (nur MCP)** | `opencode.json` | Anderes Format — `mcp`-Schlüssel auf oberster Ebene, Command als Array: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. | +| **OpenCode (volles Plugin)** | `plugin/opencode/` | 22 Auto-Capture-Hooks für Session-Lifecycle, Messages, Tools, Fehler. Zwei Slash-Befehle (`/recall`, `/remember`). Kopieren Sie `plugin/opencode/` in Ihren OpenCode-Workspace und fügen Sie den Plugin-Eintrag zu `opencode.json` hinzu. Siehe [`plugin/opencode/README.md`](../plugin/opencode/README.md) für die vollständige Hook-Tabelle + Gap-Analyse. | +| **pi** | `~/.pi/agent/extensions/agentmemory` | [`integrations/pi`](../integrations/pi/) kopieren und pi neu starten. | +| **Hermes Agent** | `~/.hermes/config.yaml` | Verwenden Sie das tiefer integrierte [Memory-Provider-Plugin](../integrations/hermes/) mit `memory.provider: agentmemory`. | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` schreibt den standardmäßigen `mcpServers`-Block. Die Hook-Payload ist feldkompatibel mit Claude Code, sodass die bestehenden 12 Hook-Skripte ohne Änderung funktionieren — verdrahten Sie sie über den Abschnitt `hooks` in derselben `settings.json`. | +| **Antigravity** (ersetzt Gemini CLI) | `mcp_config.json` (im User-Verzeichnis von Antigravity) | `agentmemory connect antigravity` schreibt den standardmäßigen `mcpServers`-Block. macOS: `~/Library/Application Support/Antigravity/User/`. Linux: `~/.config/Antigravity/User/`. Nach dem Sunset von Gemini CLI am 2026-06-18 zu nutzen. | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` schreibt die Konfig auf Benutzerebene. Workspace-Overrides liegen in `.kiro/settings/mcp.json` neben Ihrem Code. | +| **Goose** | Goose-MCP-Einstellungen-UI | Gleicher `mcpServers`-Block. | +| **Aider** | n/v | Sprechen Sie direkt mit der REST API: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. | +| **Jeder Agent (32+)** | n/v | `npx skillkit install agentmemory` erkennt den Host automatisch und merged. | + +**MCP-Clients in Sandboxen** (Flatpak / Snap / restriktive Container), die den `localhost` des Hosts nicht erreichen können: Setzen Sie zusätzlich `"AGENTMEMORY_FORCE_PROXY": "1"` im `env`-Block und lassen Sie `AGENTMEMORY_URL` auf eine Route zeigen, die die Sandbox tatsächlich erreichen kann (z. B. Ihre LAN-IP). + +### Programmatischer Zugriff (Python / Rust / Node) + +agentmemory registriert seine Kernoperationen als iii-Funktionen (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`). Jede Sprache mit einem iii-SDK kann sie direkt über `ws://localhost:49134` aufrufen — kein separater REST-Client pro Sprache nötig. + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +Vollständiges Beispiel: [`examples/python/`](../examples/python/) (Quickstart + Beobachtungs-/Recall-Fluss). REST auf `:3111` bleibt verfügbar für Hosts ohne iii-Runtime. + +### Aus den Quellen + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +Das startet agentmemory mit einer lokalen `iii-engine`, falls `iii` bereits installiert ist, oder fällt auf Docker Compose zurück, falls Docker vorhanden ist. REST, Streams und der Viewer binden sich standardmäßig an `127.0.0.1`. + +`iii-engine` manuell installieren. **agentmemory pinnt `iii-engine` derzeit auf `v0.11.2`** — `v0.11.6` führt ein neues Modell ein, alles per `iii worker add` zu sandboxen, für das agentmemory noch nicht refaktoriert wurde. Der Pin wird aufgehoben, sobald die Refaktorierung erfolgt ist. Überschreiben Sie mit `AGENTMEMORY_III_VERSION=`, wenn Sie manuell auf das Sandbox-Modell migriert sind. + +- **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:** `aarch64-apple-darwin` durch `x86_64-apple-darwin` ersetzen +- **Linux x64:** durch `x86_64-unknown-linux-gnu` ersetzen +- **Linux arm64:** durch `aarch64-unknown-linux-gnu` ersetzen +- **Windows:** `iii-x86_64-pc-windows-msvc.zip` von [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2) herunterladen, `iii.exe` extrahieren, zum PATH hinzufügen + +Oder Docker verwenden (die mitgelieferte `docker-compose.yml` zieht `iiidev/iii:0.11.2`). Vollständige Doku: [iii.dev/docs](https://iii.dev/docs). + +### Windows + +agentmemory läuft auf Windows 10/11, aber das Node.js-Paket allein genügt nicht — Sie brauchen außerdem das `iii-engine`-Runtime (ein separates natives Binary) als Hintergrundprozess. Der offizielle Upstream-Installer ist ein `sh`-Skript, und es gibt heute weder einen PowerShell-Installer noch ein scoop/winget-Paket, daher haben Windows-Nutzer zwei Wege: + +**Option A — Vorgebautes Windows-Binary (empfohlen):** + +```powershell +# 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 in your browser +# (we pin to v0.11.2 until agentmemory refactors for the new sandbox +# model that engine v0.11.6+ requires) +# 2. Download iii-x86_64-pc-windows-msvc.zip +# (or iii-aarch64-pc-windows-msvc.zip if you're on an ARM machine) +# 3. Extract iii.exe somewhere on PATH, or place it at: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory checks that location automatically) +# 4. Verify: +iii --version +# Should print: 0.11.2 + +# 5. Then run agentmemory as usual: +npx -y @agentmemory/agentmemory +``` + +**Option B — Docker Desktop:** + +```powershell +# 1. Install Docker Desktop for Windows +# 2. Start Docker Desktop and make sure the engine is running +# 3. Run agentmemory — it will auto-start the bundled compose file: +npx -y @agentmemory/agentmemory +``` + +**Option C — Nur Standalone-MCP (ohne Engine):** Wenn Sie nur die MCP-Tools für Ihren Agenten brauchen und weder REST API, Viewer noch Cron-Jobs, überspringen Sie die Engine ganz: + +```powershell +npx -y @agentmemory/agentmemory mcp +# or via the shim package: +npx -y @agentmemory/mcp +``` + +**Diagnose unter Windows:** Wenn `npx @agentmemory/agentmemory` fehlschlägt, mit `--verbose` neu starten, um das tatsächliche Engine-stderr zu sehen. Häufige Fehlerbilder: + +| Symptom | Lösung | +|---|---| +| `iii-engine process started`, dann `did not become ready within 15s` | Engine ist beim Start abgestürzt — mit `--verbose` neu starten, stderr prüfen | +| `Could not start iii-engine` | Weder `iii.exe` noch Docker installiert. Siehe Option A oder B oben | +| Port-Konflikt | `netstat -ano \| findstr :3111`, um zu sehen, was gebunden ist, dann beenden oder `--port ` verwenden | +| Docker-Fallback wird übersprungen, obwohl Docker installiert ist | Stellen Sie sicher, dass Docker Desktop tatsächlich läuft (Taskleisten-Icon) | + +> Hinweis: Die iii-**Engine** ist ein vorgebautes Binary, kein Cargo-Crate — versuche nicht, sie per `cargo install` zu installieren. (Die iii-**SDKs** sind auf crates.io, npm und PyPI veröffentlicht, aber agentmemory benötigt sie nicht.) Unterstützte Engine-Installationsmethoden, alle auf v0.11.2 gepinnt: das vorgebaute v0.11.2-Binary oben, das Upstream-`sh`-Installationsskript **mit dem Versions-Pin** `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux) und das Docker-Image `iiidev/iii:0.11.2`. Ein bloßes `install.sh | sh` installiert die **neueste** Engine, die agentmemory nicht unterstützt — übergib immer `VERSION=0.11.2`. Am einfachsten von allen: Führe einfach `npx @agentmemory/agentmemory` aus, das die gepinnte Engine für dich nach `~/.agentmemory/bin` holt. + +--- + +

Deployment

+ +Ein-Klick-Vorlagen für gemanagte Hosts. Jede liefert ein autonomes +Dockerfile aus, das `@agentmemory/agentmemory` aus npm bezieht und das +iii-engine-Binary aus dem offiziellen `iiidev/iii`-Image vom Docker Hub +kopiert — keine vorgebaute agentmemory-Image-Erforderlichkeit. Persistenter +Speicher wird unter `/data` gemountet; der Entrypoint beim ersten Boot +überschreibt die per npm gelieferte iii-Konfig (die `127.0.0.1` bindet) +mit einer deploy-tauglichen Variante, die `0.0.0.0` bindet und absolute +`/data`-Pfade verwendet, generiert das HMAC-Secret und senkt die +Privilegien von `root` auf `node` via `gosu`, bevor er die agentmemory-CLI +exec't. + +

+ Deploy to fly.io + Deploy to Railway +

+ +Der Ein-Klick-Deploy-Button von Render erfordert eine `render.yaml` im Repository-Root, das wir bewusst sauber halten. Verwenden Sie den Render-Blueprint-Fluss, dokumentiert in [`deploy/render/`](./deploy/render/README.md), um manuell auf das im Repo liegende Blueprint zu zeigen. + +Vollständige Setup-Details (HMAC-Capture, Viewer-SSH-Tunnel, Rotation, Backup, Kostenuntergrenzen) finden Sie in [`deploy/`](./deploy/README.md): + +- [`deploy/fly`](./deploy/fly/README.md) — Einzelmaschine mit + `auto_stop_machines = "stop"`; am günstigsten im Leerlauf. +- [`deploy/railway`](./deploy/railway/README.md) — Hobby-Plan mit Pauschalpreis, + Volume im Dashboard. +- [`deploy/render`](./deploy/render/README.md) — Blueprint-Fluss, + automatische Disk-Snapshots auf bezahlten Plänen. +- [`deploy/coolify`](./deploy/coolify/README.md) — self-hosted auf Ihrem + eigenen VPS via [Coolify](https://coolify.io/self-hosted); derselbe + Docker-Compose-Stack, Sie besitzen Host und Daten. + +Nur Port `3111` wird veröffentlicht. Der Viewer auf `3113` bleibt im +Container an Loopback gebunden — jedes Template-README dokumentiert +das SSH-Tunnel-Muster, um ihn zu erreichen. + +--- + +

Warum agentmemory

+ +Jeder Coding-Agent vergisst alles, wenn die Session endet. Sie verschwenden die ersten 5 Minuten jeder Session damit, Ihren Stack erneut zu erklären. agentmemory läuft im Hintergrund und beseitigt das vollständig. + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### vs. eingebautes Agent-Memory + +Jeder KI-Coding-Agent kommt mit eingebautem Memory — Claude Code hat `MEMORY.md`, Cursor hat Notepads, Cline hat Memory Bank. Das funktioniert wie Klebezettel. agentmemory ist die durchsuchbare Datenbank hinter den Klebezetteln. + +| | Eingebaut (CLAUDE.md) | agentmemory | +|---|---|---| +| Skalierung | 200-Zeilen-Limit | Unbegrenzt | +| Suche | Lädt alles in den Kontext | BM25 + Vector + Graph (nur Top-K) | +| Token-Kosten | 22K+ bei 240 Beobachtungen | ~1.900 Tokens (92 % weniger) | +| Agentenübergreifend | Dateien pro Agent | MCP + REST (jeder Agent) | +| Koordination | Keine | Leases, Signale, Actions, Routinen | +| Observability | Dateien manuell lesen | Echtzeit-Viewer auf :3113 | + +--- + +

Funktionsweise

+ +### Memory-Pipeline + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4-stufige Memory-Konsolidierung + +Inspiriert davon, wie menschliche Gehirne Erinnerungen verarbeiten — nicht unähnlich der Schlafkonsolidierung. + +| Stufe | Was | Analogie | +|------|------|---------| +| **Working** | Rohbeobachtungen aus Tool-Nutzung | Kurzzeitgedächtnis | +| **Episodic** | Komprimierte Session-Zusammenfassungen | „Was passiert ist" | +| **Semantic** | Extrahierte Fakten und Muster | „Was ich weiß" | +| **Procedural** | Workflows und Entscheidungsmuster | „Wie es geht" | + +Erinnerungen klingen mit der Zeit ab (Ebbinghaus-Kurve). Häufig abgerufene Erinnerungen werden verstärkt. Veraltete Erinnerungen werden automatisch evakuiert. Widersprüche werden erkannt und aufgelöst. + +### Was erfasst wird + +| Hook | Erfasst | +|------|----------| +| `SessionStart` | Projektpfad, Session-ID | +| `UserPromptSubmit` | Benutzer-Prompts (Privacy-gefiltert) | +| `PreToolUse` | Datei-Zugriffsmuster + angereicherter Kontext | +| `PostToolUse` | Tool-Name, Eingabe, Ausgabe | +| `PostToolUseFailure` | Fehlerkontext | +| `PreCompact` | Re-injiziert Memory vor der Kompaktierung | +| `SubagentStart/Stop` | Sub-Agent-Lifecycle | +| `Stop` | Zusammenfassung am Session-Ende | +| `SessionEnd` | Session-Abschluss-Marker | + +### Kernfähigkeiten + +| Fähigkeit | Beschreibung | +|---|---| +| **Automatische Erfassung** | Jede Tool-Nutzung via Hooks aufgezeichnet — null manueller Aufwand | +| **Semantische Suche** | BM25 + Vector + Knowledge Graph mit RRF-Fusion | +| **Memory-Evolution** | Versionierung, Supersession, Beziehungsgraphen | +| **Auto-Vergessen** | TTL-Ablauf, Widerspruchserkennung, Wichtigkeits-Eviction | +| **Privacy first** | API-Keys, Secrets, ``-Tags vor Speicherung entfernt | +| **Selbstheilung** | Circuit Breaker, Provider-Fallback-Kette, Health-Monitoring | +| **Claude-Bridge** | Bidirektionale Synchronisierung mit MEMORY.md | +| **Knowledge Graph** | Entitäten-Extraktion + BFS-Traversal | +| **Team-Memory** | Namensraum-getrennt geteilt + privat über Teammitglieder hinweg | +| **Zitations-Provenienz** | Jedes Memory bis zu Ursprungsbeobachtungen zurückverfolgen | +| **Git-Snapshots** | Memory-Stand versionieren, zurückrollen und diffen | + +--- + + + +Triple-Stream-Retrieval, das drei Signale kombiniert: + +| Stream | Was es tut | Wann | +|---|---|---| +| **BM25** | Gestemmter Keyword-Abgleich mit Synonymerweiterung | Immer aktiv | +| **Vector** | Cosinus-Ähnlichkeit über dichte Embeddings | Embedding-Provider konfiguriert | +| **Graph** | Knowledge-Graph-Traversal via Entitäten-Abgleich | Entitäten in der Anfrage erkannt | + +Verschmolzen mit Reciprocal Rank Fusion (RRF, k=60) und session-diversifiziert (max. 3 Ergebnisse pro Session). + +BM25 tokenisiert Griechisch, Kyrillisch, Hebräisch, Arabisch und akzentuiertes Latein standardmäßig. Für Erinnerungen in Chinesisch / Japanisch / Koreanisch installieren Sie die optionalen Segmentierer (`npm install @node-rs/jieba tiny-segmenter`), um CJK-Folgen in Worttokens aufzuteilen; ohne sie fällt agentmemory weich auf eine Tokenisierung als gesamte Folge zurück und gibt einmalig einen Hinweis auf stderr aus. + +### Embedding-Provider + +agentmemory erkennt Ihren Provider automatisch. Für die besten Ergebnisse installieren Sie lokale Embeddings (kostenlos): + +```bash +npm install @xenova/transformers +``` + +| Provider | Modell | Kosten | Hinweise | +|---|---|---|---| +| **Lokal (empfohlen)** | `all-MiniLM-L6-v2` | Kostenlos | Offline, +8 pp Recall gegenüber BM25 allein | +| Gemini | `gemini-embedding-001` | Free Tier | 100+ Sprachen, 768/1536/3072 Dims (MRL), 2048-Token-Eingabe. Ersetzt `text-embedding-004` ([deprecated, Abschaltung 14. Jan. 2026](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | 0,02 $/1M | Höchste Qualität | +| Voyage AI | `voyage-code-3` | Kostenpflichtig | Auf Code optimiert | +| Cohere | `embed-english-v3.0` | Testzugang | Allzweck | +| OpenRouter | Beliebiges Modell | Variabel | Multi-Modell-Proxy | + +--- + +

MCP-Server

+ +53 Tools, 6 Resources, 3 Prompts und 4 Skills — das umfassendste MCP-Memory-Toolkit für jeden Agenten. + +> **MCP-Shim vs. voller Server:** Das veröffentlichte `@agentmemory/mcp`-Paket ist ein dünnes Shim. Es legt die volle 51-Tool-Oberfläche **nur dann** offen, wenn es per `AGENTMEMORY_URL` einen laufenden agentmemory-Server erreichen kann (Proxy-Modus). Ohne erreichbaren Server fällt das Shim auf einen lokalen 7-Tool-Satz zurück (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). Die Umgebungsvariable `AGENTMEMORY_TOOLS=core|all` ist ein *serverseitiger* Schalter — sie im `env`-Block des Shims zu setzen hat keinen Effekt. Wenn Sie in Cursor / OpenCode / Gemini CLI nur 7 Tools sehen, starten Sie `npx @agentmemory/agentmemory` (oder den Docker-Stack) und setzen Sie `AGENTMEMORY_URL=http://localhost:3111`. + +### 51 Tools + +
+Core-Tools (immer verfügbar) + +| Tool | Beschreibung | +|------|-------------| +| `memory_recall` | Vergangene Beobachtungen durchsuchen | +| `memory_compress_file` | Markdown-Dateien unter Erhalt der Struktur komprimieren | +| `memory_save` | Erkenntnis, Entscheidung oder Muster speichern | +| `memory_patterns` | Wiederkehrende Muster erkennen | +| `memory_smart_search` | Hybride semantische + Keyword-Suche | +| `memory_file_history` | Vergangene Beobachtungen zu bestimmten Dateien | +| `memory_sessions` | Letzte Sessions auflisten | +| `memory_timeline` | Chronologische Beobachtungen | +| `memory_profile` | Projektprofil (Konzepte, Dateien, Muster) | +| `memory_export` | Alle Memory-Daten exportieren | +| `memory_relations` | Beziehungsgraph abfragen | + +
+ +
+Erweiterte Tools (insgesamt 51 — AGENTMEMORY_TOOLS=all setzen) + +| Tool | Beschreibung | +|------|-------------| +| `memory_patterns` | Wiederkehrende Muster erkennen | +| `memory_timeline` | Chronologische Beobachtungen | +| `memory_relations` | Beziehungsgraph abfragen | +| `memory_graph_query` | Knowledge-Graph-Traversal | +| `memory_consolidate` | 4-stufige Konsolidierung ausführen | +| `memory_claude_bridge_sync` | Mit MEMORY.md synchronisieren | +| `memory_team_share` | Mit Teammitgliedern teilen | +| `memory_team_feed` | Kürzlich geteilte Einträge | +| `memory_audit` | Audit-Trail der Operationen | +| `memory_governance_delete` | Mit Audit-Trail löschen | +| `memory_snapshot_create` | Git-versionierter Snapshot | +| `memory_action_create` | Arbeitspakete mit Abhängigkeiten anlegen | +| `memory_action_update` | Action-Status aktualisieren | +| `memory_frontier` | Entblockte Actions nach Priorität sortiert | +| `memory_next` | Einzelne wichtigste nächste Action | +| `memory_lease` | Exklusive Action-Leases (Multi-Agent) | +| `memory_routine_run` | Workflow-Routinen instanziieren | +| `memory_signal_send` | Inter-Agent-Messaging | +| `memory_signal_read` | Nachrichten mit Empfangsquittungen lesen | +| `memory_checkpoint` | Externe Bedingungs-Gates | +| `memory_mesh_sync` | P2P-Sync zwischen Instanzen | +| `memory_sentinel_create` | Ereignisgesteuerte Watcher | +| `memory_sentinel_trigger` | Sentinels extern auslösen | +| `memory_sketch_create` | Ephemere Action-Graphen | +| `memory_sketch_promote` | In permanent überführen | +| `memory_crystallize` | Action-Ketten kompaktieren | +| `memory_diagnose` | Health-Checks | +| `memory_heal` | Festsitzenden Zustand auto-fixen | +| `memory_facet_tag` | Dimension:Wert-Tags | +| `memory_facet_query` | Nach Facetten-Tags abfragen | +| `memory_verify` | Provenienz nachverfolgen | + +
+ +### 6 Resources · 3 Prompts · 4 Skills + +| Typ | Name | Beschreibung | +|------|------|-------------| +| Resource | `agentmemory://status` | Health, Session-Anzahl, Memory-Anzahl | +| Resource | `agentmemory://project/{name}/profile` | Projektspezifische Intelligenz | +| Resource | `agentmemory://memories/latest` | Die 10 neuesten aktiven Erinnerungen | +| Resource | `agentmemory://graph/stats` | Knowledge-Graph-Statistiken | +| Prompt | `recall_context` | Suche + Rückgabe von Kontext-Nachrichten | +| Prompt | `session_handoff` | Handoff-Daten zwischen Agenten | +| Prompt | `detect_patterns` | Wiederkehrende Muster analysieren | +| Skill | `/recall` | Memory durchsuchen | +| Skill | `/remember` | Im Langzeit-Memory speichern | +| Skill | `/session-history` | Zusammenfassungen letzter Sessions | +| Skill | `/forget` | Beobachtungen / Sessions löschen | + +### Standalone MCP + +Ohne den vollen Server laufen lassen — für jeden MCP-Client. Eines der folgenden geht: + +```bash +npx -y @agentmemory/agentmemory mcp # canonical (always available) +npx -y @agentmemory/mcp # shim package alias +``` + +Oder zur MCP-Konfig Ihres Agenten hinzufügen: + +Die meisten Agenten (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +Fügen Sie den `agentmemory`-Eintrag in das vorhandene `mcpServers`-Objekt Ihres Hosts ein, statt die Datei zu ersetzen. Für Sandbox-Clients, die den `localhost` des Hosts nicht erreichen können, fügen Sie `"AGENTMEMORY_FORCE_PROXY": "1"` zum env-Block hinzu und lassen `AGENTMEMORY_URL` auf eine Route zeigen, die die Sandbox erreicht. + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Plugin-Datei aus dem Repo kopieren: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Echtzeit-Viewer

+ +Startet automatisch auf Port `3113`. Live-Beobachtungs-Stream, Session-Explorer, Memory-Browser, Knowledge-Graph-Visualisierung und Health-Dashboard. + +```bash +open http://localhost:3113 +``` + +Der Viewer-Server bindet sich standardmäßig an `127.0.0.1`. Der per REST ausgelieferte `/agentmemory/viewer`-Endpunkt folgt den üblichen `AGENTMEMORY_SECRET`-Bearer-Token-Regeln. CSP-Header verwenden eine Skript-Nonce pro Response und deaktivieren Inline-Handler-Attribute (`script-src-attr 'none'`). + +--- + +

iii Console

+ +Der Viewer auf `:3113` zeigt, was Ihr Agent **gespeichert hat**. Die [iii console](https://iii.dev/docs/console) zeigt, was Ihr Agent **getan hat** — jede Memory-Operation als OpenTelemetry-Trace, jeden KV-Eintrag editierbar, jede Funktion aufrufbar, jeden Stream abgreifbar. Zwei Fenster auf dasselbe Memory: eines produktnah, eines engine-nah. + +Sehen Sie, wie ein `memory_smart_search` feuert, und beobachten Sie BM25-Scan → Embedding-Lookup → RRF-Fusion → Reranker als Wasserfall. Editieren Sie einen festsitzenden Konsolidierungs-Timer im KV-Browser. Spielen Sie einen `PostToolUse`-Hook mit angepasster Payload erneut ab. Pinnen Sie den WebSocket-Stream an und sehen Sie Beobachtungen live eintrudeln. + +agentmemory liefert das umsonst, weil jede Funktion, jeder Trigger, jeder State-Scope und jeder Stream eine iii-Primitive ist — nichts Eigenes, nichts zu instrumentieren. + +

+ iii console Workers-Seite — verbundene Worker, einschließlich agentmemory-Instanzen mit Live-Funktionszahlen und Runtime-Metadaten +
+ Workers-Seite: jeder verbundene Worker — einschließlich agentmemory selbst — mit PID, Funktionsanzahl, Runtime und last-seen. +

+ +**Bereits installiert.** Die Console wird mit `iii` ausgeliefert — kein separater Installer. + +**Neben agentmemory starten:** + +```bash +# agentmemory viewer holds port 3113, so run the console on 3114. +# Engine REST (3111), WebSocket (3112), and bridge (49134) defaults match agentmemory. +iii console --port 3114 +``` + +Dann `http://localhost:3114` öffnen. `--enable-flow` ergänzen für die experimentelle Architektur-Graph-Seite. + +Engine-Endpunkte nur überschreiben, wenn Sie sie verschoben haben: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**Was Sie aus der Console heraus tun können:** + +| Seite | Verwenden Sie sie für | +|------|-----------| +| **Workers** | Jeden verbundenen Worker und seine Live-Metriken sehen — einschließlich des agentmemory-Workers selbst. | +| **Functions** | Jede Funktion von agentmemory direkt mit einer JSON-Payload aufrufen — handlich zum Testen von `memory.recall`, `memory.consolidate`, `graph.query` ohne Client zu verdrahten. | +| **Triggers** | HTTP-, Cron-, Event- und State-Trigger erneut abspielen — den Konsolidierungs-Cron manuell auslösen, eine HTTP-Route wiederholen, einen State-Change emittieren. | +| **States** | KV-Browser mit vollem CRUD — Sessions, Memory-Slots, Lifecycle-Timer, Embedding-Index — Werte direkt bearbeiten. | +| **Streams** | Live-WebSocket-Monitor für Memory-Schreibvorgänge, Hook-Events und Beobachtungsupdates, wie sie durch iii-Streams fließen. | +| **Queues** | Durable Queue-Topics + Dead-Letter-Verwaltung. Fehlgeschlagene Embedding-/Kompressions-Jobs wiederholen oder verwerfen. | +| **Traces** | OpenTelemetry-Wasserfall- / Flame- / Service-Breakdown-Ansichten. Nach `trace_id` filtern, um exakt zu sehen, welche Funktionen, DB-Calls und Embedding-Anfragen eine einzelne `memory.search` ausgelöst hat. | +| **Logs** | Strukturierte OTEL-Logs, gefiltert und korreliert mit Trace-/Span-IDs. | +| **Config** | Runtime-Konfiguration — sehen Sie genau, mit welchen Workern, Providern und Ports Ihre Engine läuft. | +| **Flow** | (Optional, `--enable-flow`) Interaktiver Architekturgraph jedes Workers, Triggers und Streams. | + +

+ iii console Trace-Wasserfall-Ansicht mit Span-Dauer +
+ Traces: Wasserfall / Flame / Service-Breakdown für jede Memory-Operation. +

+ +**Traces sind bereits aktiv:** + +`iii-config.yaml` wird mit aktiviertem `iii-observability`-Worker ausgeliefert (`exporter: memory`, `sampling_ratio: 1.0`, Metriken + Logs). Keine zusätzliche Konfig nötig — in dem Moment, in dem agentmemory startet, emittiert jede Memory-Operation einen Trace-Span und ein strukturiertes Log, das die Console lesen kann. + +Wenn Sie stattdessen zu Jaeger/Honeycomb/Grafana Tempo exportieren wollen, ändern Sie `exporter: memory` zu `exporter: otlp` und setzen den Collector-Endpunkt gemäß der iii-Observability-Doku. + +> **Achtung:** Auf der Console selbst wird keine Auth erzwungen — lassen Sie sie an `127.0.0.1` gebunden (Standard) und stellen Sie sie niemals öffentlich bereit. + +--- + +

Powered by iii

+ +agentmemory ist **bereits eine laufende [iii](https://iii.dev)-Instanz**. Funktionen, Trigger, KV-State, Streams, OTEL-Traces — alles sind iii-Primitiven. Sie haben weder Postgres noch Redis, Express, pm2 oder Prometheus installiert, weil iii sie ersetzt. + +Das bedeutet, ein weiterer Befehl erweitert agentmemory um eine komplett neue Fähigkeit. + +### agentmemory mit einem Befehl erweitern + +```bash +iii worker add iii-pubsub # fan memory writes out to every connected instance +iii worker add iii-cron # scheduled consolidation, decay sweeps, snapshot rotation +iii worker add iii-queue # durable retries for embedding + compression jobs +iii worker add iii-observability # OTEL traces on every memory op (default on) +iii worker add iii-sandbox # run recalled code inside an isolated microVM +iii worker add iii-database # swap in a SQL-backed state adapter +iii worker add mcp # generic MCP host alongside the agentmemory MCP +``` + +Jedes `iii worker add` registriert neue Funktionen und Trigger im selben Engine, auf dem agentmemory bereits läuft. Viewer und Console übernehmen sie sofort — kein Reload, keine neue Integration, kein neuer Container. + +| `iii worker add` | Was Sie zusätzlich zu agentmemory erhalten | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | Multi-Instanz-Memory: jedes `remember` fächert auf, jedes `search` liest die Vereinigung | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Geplanter Lifecycle — nächtliche Konsolidierung, wöchentliche Snapshots, Decay nach fester Uhr | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | Durable Retries: fehlgeschlagene Embedding-/Kompressions-Jobs überleben den Neustart, keine verlorenen Beobachtungen | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | OTEL-Traces, Metriken, Logs auf jeder Funktion — in `iii-config.yaml` ab dem ersten Tag verdrahtet | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | Code, der aus `memory_recall` kommt, läuft in einer wegwerf-VM, nicht in Ihrer Shell | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | SQL-gestützter State-Adapter, wenn Sie die In-Memory-KV-Voreinstellungen überwachsen | +| [`mcp`](https://workers.iii.dev/workers/mcp) | Zusätzliche MCP-Server neben dem von agentmemory aufstellen, die sich denselben Engine teilen | + +Volle Registry: [workers.iii.dev](https://workers.iii.dev). Jeder Worker dort komponiert sich über dieselben Primitiven wie agentmemory — und das agentmemory, das Sie bereits haben, ist einer davon. + +### Was iii ersetzt + +| Traditioneller Stack | agentmemory verwendet | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + In-Memory-Vector-Index | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | iii-Engine-Worker-Supervision | +| Prometheus / Grafana | iii OTEL + Health-Monitor | +| Eigene Plugin-Systeme | `iii worker add ` | + +**118 Quelldateien · ~21.800 LOC · 950+ Tests · 123 Funktionen · 34 KV-Scopes** — alles auf drei Primitiven. Kein `agentmemory plugin install`. Das Plugin-System ist iii selbst. + +--- + +

Konfiguration

+ +### LLM-Provider + +agentmemory erkennt aus Ihrer Umgebung automatisch. Standardmäßig werden keine LLM-Aufrufe ausgeführt, solange Sie nicht einen Provider konfigurieren oder dem Claude-Abonnement-Fallback ausdrücklich zustimmen. + +| Provider | Konfig | Hinweise | +|----------|--------|-------| +| **No-op (Standard)** | Keine Konfig nötig | LLM-gestütztes Compress/Summarize ist DEAKTIVIERT. Synthetische BM25-Kompression + Recall funktionieren weiter. Siehe `AGENTMEMORY_ALLOW_AGENT_SDK` unten, falls Sie früher auf den Claude-Abonnement-Fallback gesetzt haben. | +| Anthropic API | `ANTHROPIC_API_KEY` | Abrechnung pro Token | +| MiniMax | `MINIMAX_API_KEY` | Anthropic-kompatibel | +| Gemini | `GEMINI_API_KEY` | Aktiviert zusätzlich Embeddings | +| OpenRouter | `OPENROUTER_API_KEY` | Beliebiges Modell | +| Claude-Abonnement-Fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Nur als Opt-in. Startet `@anthropic-ai/claude-agent-sdk`-Sessions — verursachte früher unbegrenzte Stop-Hook-Rekursion, daher nicht mehr Standard. | + +### Kostenbewusste Modellwahl + +Hintergrund-Kompression läuft bei jeder Beobachtung, daher beeinflusst die Modellwahl die monatlichen Kosten spürbar. Erfasste Lastdaten: 635 Requests / 888K Tokens / 35 Stunden aktive Nutzung, gegen drei OpenRouter-Modelle zu den Preisen vom 2026-05-23. + +| Stufe | Modell | Eingabe / 1M | Ausgabe / 1M | Kosten für die erfassten 35 h | Hinweise | +|------|-------|------------|-------------|---------------------------|-------| +| Empfohlen | `deepseek/deepseek-v4-pro` | 0,435 $ | 0,87 $ | ~0,46 $ | Solide Kompressions-/Summarize-Qualität zu ~10× geringeren Kosten als Sonnet. | +| Empfohlen | `deepseek/deepseek-chat` | 0,27 $ | 1,10 $ | ~0,40 $ | Älter, aber für reine Kompressions-Workloads weiterhin in Ordnung. | +| Empfohlen | `qwen/qwen3-coder` | 0,45 $ | 1,80 $ | ~0,55 $ | Starkes Code-Reasoning, wenn Ihre Sessions stark codelastig sind. | +| Premium | `anthropic/claude-sonnet-4.6` | 3,00 $ | 15,00 $ | ~5,02 $ | Hohe Qualität, aber teuer für dauerhafte Hintergrundarbeit. | +| Premium | `openai/gpt-4o` | 2,50 $ | 10,00 $ | ~4,20 $ | Ähnliche Stufe wie Sonnet. | +| Vermeiden | `anthropic/claude-opus-4.6` | 15,00 $ | 75,00 $ | ~25+ $ | Reasoning-Klasse-Modell; massive Überausgabe für Kompression. | + +agentmemory gibt eine Runtime-Warnung aus, wenn `OPENROUTER_MODEL` auf ein Premium-Tier-Muster passt. Setzen Sie `AGENTMEMORY_SUPPRESS_COST_WARNING=1`, um sie zum Schweigen zu bringen, sobald Sie eine bewusste Wahl getroffen haben. + +Qualitäts-Kosten-Abwägung für Memory-Arbeit: Kompression ist eine Summarize-Aufgabe mit eher lockerer Qualitätsanforderung (der Agent liest die Zusammenfassung erneut, nicht der Benutzer). DeepSeek-V4-Pro / Qwen3-Coder landen bei dieser Aufgabe innerhalb von Rundungsfehlern an Sonnet, bei ~10× weniger Kosten. Heben Sie Premium-Modelle für Anfragen auf, die Sie direkt lesen. + +Quellen: [OpenRouter-Preise für Sonnet 4.6](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing), [DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro), [DeepSeek-Preis-Hinweise](https://api-docs.deepseek.com/quick_start/pricing/). + +### Multi-Agent-Memory (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +In Multi-Agent-Setups, in denen sich mehrere Rollen einen agentmemory-Server teilen (architect / developer / reviewer / researcher / support-agent), markiert `AGENT_ID` jede Schreibaktion mit der Rolle, die sie ausgelöst hat. `AGENTMEMORY_AGENT_SCOPE` steuert, ob der Recall nach diesem Tag filtert. + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" +``` + +Zwei Modi: + +| Modus | Schreibvorgänge markieren | Recall filtern | Wann verwenden | +|------|------------|---------------|-------------| +| `shared` (Standard) | ja | nein | Agentenübergreifender Kontext mit Audit-Trail. Architect sieht, was Developer notiert hat, aber jede Zeile vermerkt, wer es gesagt hat. | +| `isolated` | ja | ja | Strikte Trennung. Architect sieht niemals Beobachtungen / Erinnerungen / Sessions von Developer. | + +Was getaggt wird, wenn `AGENT_ID` gesetzt ist: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. Die Rolle fließt von `api::session::start` → `mem::observe` → `mem::compress` → KV. + +Was im Isolated-Modus gefiltert wird: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Jeder Endpunkt akzeptiert `?agentId=` als Per-Request-Override und `?agentId=*`, um sich komplett aus dem env-Scope auszuklinken. `/memories` akzeptiert zudem `?includeOrphans=true`, um Pre-AGENT_ID-Erinnerungen, deren `agentId` undefiniert ist, sichtbar zu machen. + +Per-Call-Override auf SDK-/REST-Ebene: jeder mutierende Endpunkt (`/session/start`, `/remember`) akzeptiert ein `agentId`-Feld im Request-Body, das die env-Variable überschreibt. Nützlich für Runtimes, die viele Rollen durch einen einzelnen Serverprozess routen. + +Wenn `AGENT_ID` nicht gesetzt ist, bleibt Memory unscoped (Legacy-Verhalten, keine Tags, keine Filter). + +### Ports + +agentmemory + iii-engine binden standardmäßig vier Ports. Wenn ein Neustart mit `port in use` fehlschlägt, sagt Ihnen diese Tabelle, nach welchem Prozess Sie suchen müssen. + +| Port | Prozess | Zweck | Env-Override | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | Interner Streams-Worker (von agentmemory + Viewer verwendet) | `III_STREAMS_PORT` | +| `3113` | agentmemory | Echtzeit-Viewer (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — Worker registrieren sich hier, OTel-Telemetrie fließt darüber | `III_ENGINE_URL` (volle URL, Standard `ws://localhost:49134`) | + +Aufräumen veralteter Prozesse, wenn Ports nach einem abgestürzten Lauf gebunden bleiben: + +```bash +# macOS / Linux — find whatever is on each port and kill it +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` räumt sowohl den Worker als auch das Engine-Pidfile bei einem geordneten Shutdown sauber auf. Das manuelle Cleanup oben ist nur für den Post-Crash-Fall nötig, in dem kein Pidfile zurückbleibt. + +### Konfigurationsdatei + +Legen Sie die agentmemory-Runtime-Konfiguration in `~/.agentmemory/.env` ab, statt Variablen in jeder Shell zu exportieren. Wenn der Viewer einen Setup-Hinweis wie `export ANTHROPIC_API_KEY=...` zeigt, kopieren Sie ihn als `ANTHROPIC_API_KEY=...` ohne `export`-Präfix in diese Datei und starten Sie agentmemory neu. + +Prozess-Umgebungsvariablen funktionieren weiterhin und haben Vorrang vor Werten in der Datei. + +Unter Windows liegt dieselbe Datei unter `%USERPROFILE%\.agentmemory\.env`: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +Um mit einem Claude Code Pro/Max-Abonnement statt eines API-Schlüssels zu testen, stimmen Sie explizit zu: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +Aktivieren Sie Graph- oder Konsolidierungs-Features in derselben Datei, falls gewünscht: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### Umgebungsvariablen + +`~/.agentmemory/.env` anlegen: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +124 Endpunkte auf Port `3111`. Die REST API bindet sich standardmäßig an `127.0.0.1`. Geschützte Endpunkte verlangen `Authorization: Bearer `, wenn `AGENTMEMORY_SECRET` gesetzt ist, und Mesh-Sync-Endpunkte erfordern `AGENTMEMORY_SECRET` auf beiden Peers. + +
+Wichtige Endpunkte + +| Methode | Pfad | Beschreibung | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | Health-Check (immer öffentlich) | +| `POST` | `/agentmemory/session/start` | Session starten + Kontext holen | +| `POST` | `/agentmemory/session/end` | Session beenden | +| `POST` | `/agentmemory/observe` | Beobachtung erfassen | +| `POST` | `/agentmemory/smart-search` | Hybride Suche | +| `POST` | `/agentmemory/context` | Kontext erzeugen | +| `POST` | `/agentmemory/remember` | In Langzeit-Memory speichern | +| `POST` | `/agentmemory/forget` | Beobachtungen löschen | +| `POST` | `/agentmemory/enrich` | Dateikontext + Erinnerungen + Bugs | +| `GET` | `/agentmemory/profile` | Projektprofil | +| `GET` | `/agentmemory/export` | Alle Daten exportieren | +| `POST` | `/agentmemory/import` | Aus JSON importieren | +| `POST` | `/agentmemory/graph/query` | Knowledge-Graph-Anfrage | +| `POST` | `/agentmemory/team/share` | Mit Team teilen | +| `GET` | `/agentmemory/audit` | Audit-Trail | + +Volle Endpunktliste: [`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Entwicklung

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 950+ tests +npm run test:integration # API tests (requires running services) +``` + +**Voraussetzungen:** Node.js >= 20, [iii-engine](https://iii.dev/docs) oder Docker + +

Lizenz

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.es-ES.md b/READMEs/README.es-ES.md new file mode 100644 index 0000000..3245e21 --- /dev/null +++ b/READMEs/README.es-ES.md @@ -0,0 +1,1369 @@ +

+ agentmemory — Memoria persistente para agentes de codificación con IA +

+ +

+ + Tu agente de codificación lo recuerda todo. Se acabó volver a explicarlo. + Built on iii engine +
+ Memoria persistente para Claude Code, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode y cualquier cliente MCP. +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Documento de diseño: 1200 stars / 172 forks en el gist +

+ +

+ El gist extiende el patrón LLM Wiki de Karpathy con puntuación de confianza, ciclo de vida, grafos de conocimiento y búsqueda híbrida: agentmemory es la implementación. +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ Demostración de agentmemory +

+ +

+ Instalación • + Inicio rápido • + Benchmarks • + Comparativa • + Agentes • + Cómo funciona • + MCP • + Visor • + iii Console • + Powered by iii • + Configuración • + API +

+ +--- + +## Install + +```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 connect claude-code # wire your agent (also: codex, cursor, gemini-cli, ...) +``` + +O mediante `npx` (sin instalación): + +```bash +npx @agentmemory/agentmemory +``` + +Aviso — npx cachea por versión. Si un simple `npx @agentmemory/agentmemory` sirve una versión antigua, fuerza la última con `npx -y @agentmemory/agentmemory@latest`, o limpia la caché una vez con `rm -rf ~/.npm/_npx` (macOS/Linux; en Windows borra `%LOCALAPPDATA%\npm-cache\_npx`). La primera ejecución vía npx desde la v0.9.16+ pregunta si deseas instalar globalmente, de modo que el comando `agentmemory` quede disponible en cualquier lugar. + +Todas las opciones en [Inicio rápido](#quick-start) más abajo. Conexión específica por agente en [Funciona con cualquier agente](#works-with-every-agent). + +--- + +

Funciona con cualquier agente

+ +agentmemory funciona con cualquier agente que soporte hooks, MCP o REST API. Todos los agentes comparten el mismo servidor de memoria. + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+native plugin + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+native plugin + 6 hooks + MCP +
+OpenClaw
+OpenClaw
+native plugin + MCP +
+Hermes
+Hermes
+native plugin + MCP +
+pi
+pi
+native plugin + MCP +
+OpenHuman
+OpenHuman
+native Memory trait backend +
+Cursor
+Cursor
+MCP server +
+Gemini CLI
+Gemini CLI
+MCP server +
+OpenCode
+OpenCode
+22 hooks + MCP + plugin +
+Cline
+Cline
+MCP server +
+Goose
+Goose
+MCP server +
+Kilo Code
+Kilo Code
+MCP server +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP server +
+Windsurf
+Windsurf
+MCP server +
+Roo Code
+Roo Code
+MCP server +
+ +

+ Funciona con cualquier agente que hable MCP o HTTP. Un único servidor, memorias compartidas entre todos ellos. +

+ +--- + +Vuelves a explicar la misma arquitectura cada sesión. Vuelves a descubrir los mismos bugs. Vuelves a enseñar las mismas preferencias. La memoria integrada (CLAUDE.md, .cursorrules) se topa con un techo de 200 líneas y se queda obsoleta. agentmemory soluciona esto. Captura silenciosamente lo que hace tu agente, lo comprime en una memoria buscable e inyecta el contexto correcto al inicio de la siguiente sesión. Un único comando. Funciona en todos los agentes. + +**Qué cambia:** En la sesión 1 configuras autenticación JWT. En la sesión 2 pides rate limiting. El agente ya sabe que tu autenticación usa el middleware jose en `src/middleware/auth.ts`, que tus pruebas cubren la validación de tokens y que elegiste jose en lugar de jsonwebtoken por compatibilidad con Edge. Sin volver a explicar. Sin copiar y pegar. El agente simplemente lo *sabe*. + +```bash +npx @agentmemory/agentmemory +``` + +> **Novedad en v0.9.0** — Sitio de aterrizaje en [agent-memory.dev](https://agent-memory.dev), conector de sistema de ficheros (`@agentmemory/fs-watcher`), el MCP standalone ahora hace de proxy al servidor en ejecución, por lo que los hooks y el visor coinciden, política de auditoría codificada en cada ruta de borrado, y health deja de marcar `memory_critical` en procesos Node pequeños. Notas completas en [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18). + +--- + +

Benchmarks

+ + + + + + +
+ +### Precisión de recuperación + +**coding-agent-life-v1** (corpus interno, reproducible en sandbox) + +| Adaptador | P@5 | R@5 | Tasa de aciertos top-5 | Latencia p50 | +|---|---|---|---|---| +| **agentmemory hybrid** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep baseline | 0.267 | 0.967 | 15 / 15 | 0 ms | + +Tasa de aciertos top-5 del 100%. Precisión **2,2×** mejor que la baseline grep con la misma entrada. Desglose completo por tipo: [`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 preguntas) + +| Sistema | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| BM25-only fallback | 86.2% | 94.6% | 71.5% | + + + +### Ahorro de tokens + +| Enfoque | Tokens/año | Coste/año | +|---|---|---| +| Pegar todo el contexto | 19.5M+ | Imposible (excede la ventana) | +| Resumido por LLM | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + embeddings locales | ~170K | **$0** | + +
+ +> Modelo de embedding: `all-MiniLM-L6-v2` (local, gratuito, sin API key). Informes completos: [`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](../benchmark/QUALITY.md), [`benchmark/SCALE.md`](../benchmark/SCALE.md). Comparativa con la competencia: [`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory frente a mem0, Letta, Khoj, claude-mem, Hippo. + +**Reproduce en local:** [`eval/README.md`](../eval/README.md) — un harness con adaptadores intercambiables para LongMemEval `_s` (500-Q públicas) y `coding-agent-life-v1` (corpus interno de 15 sesiones). Los adaptadores grep / vector / agentmemory se puntúan en paralelo, salida NDJSON, y las scorecards publicadas quedan en [`docs/benchmarks/`](../docs/benchmarks/). + +**Funciona muy bien con [codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything) y [Graphify](https://github.com/safishamsi/graphify).** Indexado de grafos de código, pipelines de build multiagente y grafos de conocimiento más amplios sobre documentos / PDFs / imágenes / vídeos. agentmemory recuerda el trabajo; esos tres proyectos iluminan el resto de la capa de contexto. Recetas y tabla de enrutamiento por pregunta: [`docs/recipes/pairings.md`](../docs/recipes/pairings.md). + +--- + +

Comparativa

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)Built-in (CLAUDE.md)
TipoMotor de memoria + servidor MCPAPI de capa de memoriaRuntime de agente completoFichero estático
Retrieval R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/A (grep)
Captura automática12 hooks (esfuerzo manual cero)Llamadas manuales a add()El agente se edita a sí mismoEdición manual
BúsquedaBM25 + Vector + Graph (fusión RRF)Vector + GraphVector (archival)Carga todo en contexto
MultiagenteMCP + REST + leases + signalsAPI (sin coordinación)Solo dentro del runtime de LettaFicheros por agente
Dependencia de frameworkNinguna (cualquier cliente MCP)NingunaAlta (obliga a usar Letta)Formato por agente
Dependencias externasNinguna (SQLite + iii-engine)Qdrant / pgvectorPostgres + BD vectorialNinguna
Ciclo de vida de memoriaConsolidación de 4 niveles + decaimiento + auto-olvidoExtracción pasivaGestionado por el agentePoda manual
Eficiencia de tokens~1.900 tokens/sesión ($10/año)Varía según la integraciónMemoria principal en contexto22K+ tokens con 240 obs
Visor en tiempo realSí (port 3113)Dashboard en la nubeDashboard en la nubeNo
Self-hostedSí (por defecto)OpcionalOpcional
+ +--- + +

Inicio rápido

+ +Compatibilidad: esta release apunta a `iii-sdk` estable `^0.11.0` e iii-engine v0.11.x. + +### Pruébalo en 30 segundos + +```bash +# Terminal 1: start the server +npx @agentmemory/agentmemory + +# Terminal 2: seed sample data and see recall in action +npx @agentmemory/agentmemory demo +``` + +`demo` siembra 3 sesiones realistas (autenticación JWT, corrección de N+1 queries, rate limiting) y ejecuta búsquedas semánticas sobre ellas. Verás cómo encuentra "N+1 query fix" al buscar "database performance optimization" — algo que la coincidencia por palabra clave no puede hacer. + +Abre `http://localhost:3113` para ver cómo se construye la memoria en directo. + +### Recomendado: instala globalmente + +`npx` cachea por versión. Si la semana pasada ejecutaste `npx @agentmemory/agentmemory@0.9.14`, un simple `npx @agentmemory/agentmemory` puede servir la versión obsoleta 0.9.14 desde `~/.npm/_npx/`, y no la última. Instala una vez y el comando `agentmemory` funciona en cualquier sitio: + +```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 stop # tear it down +agentmemory remove # uninstall everything we created +agentmemory connect claude-code # wire one agent +agentmemory doctor # interactive diagnostics + fix prompts +``` + +A partir de v0.9.16, la primera ejecución vía npx pregunta si deseas instalar globalmente — responde `Y` una vez y listo. Si lo saltas, recurre a cualquiera de estos para un fetch limpio: + +```bash +npx -y @agentmemory/agentmemory@latest # forces latest from npm (cross-platform) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # macOS/Linux only (POSIX shell) +``` + +En Windows / PowerShell, el equivalente para limpiar caché es `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` — la opción `npx -y ...@latest` de arriba es la alternativa multiplataforma. + +### Session Replay + +Toda sesión que agentmemory registra es reproducible. Abre el visor, elige la pestaña **Replay** y desplázate por la línea de tiempo: prompts, llamadas a herramientas, resultados y respuestas se renderizan como eventos discretos con play/pause, control de velocidad (0,5×–4×) y atajos de teclado (espacio para alternar, flechas para avanzar paso a paso). + +¿Ya tienes transcripciones JSONL antiguas de Claude Code que quieras importar? + +```bash +# Import everything under the default ~/.claude/projects +npx @agentmemory/agentmemory import-jsonl + +# Or import a single file +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +Las sesiones importadas aparecen en el selector de Replay junto a las nativas. Por debajo, cada entrada se enruta a través de las funciones iii `mem::replay::load`, `mem::replay::sessions` y `mem::replay::import-jsonl` — sin servidores side-channel. + +### Actualización / Mantenimiento + +Usa el comando de mantenimiento cuando intencionadamente quieras actualizar tu runtime local: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +Aviso: este comando muta el workspace/runtime actual. Puede actualizar dependencias de JavaScript y traer la imagen Docker fijada `iiidev/iii:0.11.2`. Nunca instala un motor iii sin fijar ni más nuevo. + +Los detalles de implementación están en `src/cli.ts` (ver `runUpgrade` en torno a la región `src/cli.ts:544-595`). + +### Claude Code (un bloque, pégalo) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Claude Code sin instalar el plugin (ruta MCP standalone) + +Si conectas el servidor MCP de agentmemory directamente vía `~/.claude.json` en lugar de usar `/plugin install`, Claude Code nunca resuelve `${CLAUDE_PLUGIN_ROOT}` y tienes que apuntar los scripts de hook a rutas absolutas en `~/.claude/settings.json`. Esas rutas suelen incluir la versión de agentmemory (p. ej. `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), por lo que la siguiente actualización rompe silenciosamente todos los hooks. + +Solución: + +```bash +agentmemory connect claude-code --with-hooks +``` + +Esto fusiona los mismos comandos de hook en `~/.claude/settings.json` con rutas absolutas que apuntan al directorio `plugin/` empaquetado del paquete `@agentmemory/agentmemory` actualmente instalado. Vuelve a ejecutar el comando tras actualizar agentmemory para refrescar las rutas. Las entradas de usuario en el mismo fichero se preservan; solo se reemplazan las entradas previas de agentmemory. La ruta vía `/plugin install` sigue siendo la recomendada. +Para despliegues remotos o protegidos, lanza Claude Code con `AGENTMEMORY_URL` y `AGENTMEMORY_SECRET` definidos. El plugin pasa ambos valores a su servidor MCP empaquetado; cuando `AGENTMEMORY_URL` está vacío, el shim MCP usa `http://localhost:3111`. + +### Codex CLI (plataforma de plugins Codex) + +```bash +# 1. start the memory server in a separate terminal +npx @agentmemory/agentmemory + +# 2. register the agentmemory marketplace and install the plugin +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +El plugin de Codex se sirve desde el mismo directorio `plugin/` que el de Claude Code. Registra: + +- `@agentmemory/mcp` como servidor MCP (hace de proxy a las 51 tools cuando `AGENTMEMORY_URL` apunta a un servidor agentmemory en ejecución; cae a 7 tools en local cuando no hay servidor accesible) +- 6 hooks de ciclo de vida: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 4 skills: `/recall`, `/remember`, `/session-history`, `/forget` + +El motor de hooks de Codex inyecta `CLAUDE_PLUGIN_ROOT` en los subprocesos de hook (según [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)), por lo que los mismos scripts de hook funcionan en ambos hosts sin duplicación. Los eventos Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure son exclusivos de Claude Code y no se registran para Codex. + +#### Codex Desktop: los hooks del plugin están silenciados (con workaround) + +`CodexHooks` y `PluginHooks` son estables y están activados por defecto en [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs), pero las builds actuales de Codex Desktop no despachan el `hooks.json` local del plugin ([openai/codex#16430](https://github.com/openai/codex/issues/16430)). Las tools MCP siguen funcionando; solo faltan las observaciones del ciclo de vida. + +Hasta que se solucione upstream, replica los mismos comandos de hook en el `~/.codex/hooks.json` global: + +```bash +agentmemory connect codex --with-hooks +``` + +Esto añade un bloque idempotente a `~/.codex/hooks.json` que referencia rutas absolutas a los scripts empaquetados (no hace falta expandir `${CLAUDE_PLUGIN_ROOT}` en el ámbito de usuario). Vuelve a ejecutar el mismo comando tras actualizar agentmemory para refrescar las rutas. Las entradas de usuario en el mismo fichero se preservan; solo se reemplazan las entradas previas de agentmemory. + +
+OpenClaw (pega este prompt) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +Guía completa: [`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent (pega este prompt) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +Guía completa: [`integrations/hermes/`](../integrations/hermes/) + +
+ +### Otros agentes + +Arranca el servidor de memoria: `npx @agentmemory/agentmemory` + +La entrada de agentmemory es el **mismo bloque de servidor MCP** en cada host que use la forma `mcpServers` (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw): + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**Fusiona esta entrada en el objeto `mcpServers` existente** en el fichero de configuración del host — no reemplaces el fichero. Si el fichero ya contiene otros servidores, añade `agentmemory` junto a ellos como otra clave dentro de `mcpServers`. Si `mcpServers` no existe, pega el bloque dentro de `{ "mcpServers": { ... } }`. Los marcadores `${VAR}` heredan `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` del shell al lanzar el servidor MCP — si no están definidas se pasan como cadena vacía y el shim cae a `http://localhost:3111`. Una sola entrada cubre tanto despliegues locales como remotos (k8s / con reverse-proxy). + +| Agente | Fichero de configuración | Notas | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | Fusiona en `mcpServers`. También hay deeplink de un clic en el sitio web. | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | Fusiona en `mcpServers`. Reinicia Claude Desktop tras editar. | +| **Cline / Roo Code / Kilo Code** | Ajustes MCP de Cline (Settings UI → MCP Servers → Edit) | Mismo bloque `mcpServers`. | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Mismo bloque `mcpServers`. | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (fusión automática). | +| **OpenClaw** | Configuración MCP de OpenClaw | Mismo bloque `mcpServers`, o usa el [memory plugin](../integrations/openclaw/) más profundo. | +| **Codex CLI (solo MCP)** | `.codex/config.toml` | Forma TOML: `codex mcp add agentmemory -- npx -y @agentmemory/mcp`, o añade `[mcp_servers.agentmemory]` a mano. | +| **Codex CLI (plugin completo)** | Marketplace de plugins Codex | `codex plugin marketplace add rohitg00/agentmemory` y luego `codex plugin add agentmemory@agentmemory`. Registra MCP + 6 hooks de ciclo de vida (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skills. En Codex Desktop, ejecuta también `agentmemory connect codex --with-hooks` hasta que se mergee [openai/codex#16430](https://github.com/openai/codex/issues/16430) — los hooks de plugin están silenciados allí. | +| **OpenCode (solo MCP)** | `opencode.json` | Forma distinta — clave `mcp` en el nivel superior, comando como array: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. | +| **OpenCode (plugin completo)** | `plugin/opencode/` | 22 hooks de captura automática que cubren ciclo de vida de sesión, mensajes, tools y errores. Dos comandos slash (`/recall`, `/remember`). Copia `plugin/opencode/` a tu workspace de OpenCode y añade la entrada del plugin a `opencode.json`. Tabla completa de hooks + análisis de gaps en [`plugin/opencode/README.md`](../plugin/opencode/README.md). | +| **pi** | `~/.pi/agent/extensions/agentmemory` | Copia [`integrations/pi`](../integrations/pi/) y reinicia pi. | +| **Hermes Agent** | `~/.hermes/config.yaml` | Usa el [memory provider plugin](../integrations/hermes/) más profundo con `memory.provider: agentmemory`. | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` escribe el bloque `mcpServers` estándar. El payload de los hooks es compatible a nivel de campo con Claude Code, así que los scripts de los 12 hooks existentes funcionan sin modificación — conéctalos en la sección `hooks` del mismo `settings.json`. | +| **Antigravity** (sustituye a Gemini CLI) | `mcp_config.json` (en el directorio User de Antigravity) | `agentmemory connect antigravity` escribe el bloque `mcpServers` estándar. macOS: `~/Library/Application Support/Antigravity/User/`. Linux: `~/.config/Antigravity/User/`. Úsalo tras el sunset de Gemini CLI del 2026-06-18. | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` escribe la configuración de nivel usuario. Los overrides por workspace van en `.kiro/settings/mcp.json` junto a tu código. | +| **Goose** | UI de ajustes MCP de Goose | Mismo bloque `mcpServers`. | +| **Aider** | n/a | Habla directamente con la REST API: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. | +| **Cualquier agente (32+)** | n/a | `npx skillkit install agentmemory` auto-detecta el host y fusiona. | + +**Clientes MCP en sandbox** (Flatpak / Snap / contenedores restrictivos) que no pueden alcanzar el `localhost` del host: añade también `"AGENTMEMORY_FORCE_PROXY": "1"` al bloque `env`, y apunta `AGENTMEMORY_URL` a una ruta que el sandbox sí pueda alcanzar (p. ej. tu IP de LAN). + +### Acceso programático (Python / Rust / Node) + +agentmemory registra sus operaciones principales como funciones iii (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`). Cualquier lenguaje con un SDK iii puede llamarlas directamente sobre `ws://localhost:49134` — sin un cliente REST separado por lenguaje. + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +Ejemplo trabajado: [`examples/python/`](../examples/python/) (quickstart + flujo de observación/recall). La REST en `:3111` sigue disponible para hosts sin runtime iii. + +### Desde el código fuente + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +Esto arranca agentmemory con un `iii-engine` local si `iii` ya está instalado, o cae a Docker Compose si hay Docker disponible. REST, streams y el visor se enlazan a `127.0.0.1` por defecto. + +Instala `iii-engine` manualmente. **agentmemory actualmente fija `iii-engine` a `v0.11.2`** — `v0.11.6` introduce un nuevo modelo que sandboxea todo vía `iii worker add`, y agentmemory aún no se ha refactorizado para él. La fijación se levantará cuando aterrice el refactor. Sobrescribe con `AGENTMEMORY_III_VERSION=` si has migrado al modelo sandbox manualmente. + +- **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:** cambia `aarch64-apple-darwin` por `x86_64-apple-darwin` +- **Linux x64:** cambia por `x86_64-unknown-linux-gnu` +- **Linux arm64:** cambia por `aarch64-unknown-linux-gnu` +- **Windows:** descarga `iii-x86_64-pc-windows-msvc.zip` desde [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2), extrae `iii.exe`, añádelo al PATH + +O usa Docker (el `docker-compose.yml` empaquetado descarga `iiidev/iii:0.11.2`). Documentación completa: [iii.dev/docs](https://iii.dev/docs). + +### Windows + +agentmemory funciona en Windows 10/11, pero el paquete de Node.js por sí solo no es suficiente — también necesitas el runtime `iii-engine` (un binario nativo aparte) como proceso en segundo plano. El instalador oficial upstream es un script `sh` y hoy no existe un instalador PowerShell ni paquete scoop/winget, así que los usuarios de Windows tienen dos rutas: + +**Opción A — Binario Windows preconstruido (recomendado):** + +```powershell +# 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 in your browser +# (we pin to v0.11.2 until agentmemory refactors for the new sandbox +# model that engine v0.11.6+ requires) +# 2. Download iii-x86_64-pc-windows-msvc.zip +# (or iii-aarch64-pc-windows-msvc.zip if you're on an ARM machine) +# 3. Extract iii.exe somewhere on PATH, or place it at: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory checks that location automatically) +# 4. Verify: +iii --version +# Should print: 0.11.2 + +# 5. Then run agentmemory as usual: +npx -y @agentmemory/agentmemory +``` + +**Opción B — Docker Desktop:** + +```powershell +# 1. Install Docker Desktop for Windows +# 2. Start Docker Desktop and make sure the engine is running +# 3. Run agentmemory — it will auto-start the bundled compose file: +npx -y @agentmemory/agentmemory +``` + +**Opción C — Solo MCP standalone (sin engine):** si solo necesitas las tools MCP para tu agente y no necesitas la REST API, el visor ni los cron jobs, sáltate el engine por completo: + +```powershell +npx -y @agentmemory/agentmemory mcp +# or via the shim package: +npx -y @agentmemory/mcp +``` + +**Diagnóstico para Windows:** si `npx @agentmemory/agentmemory` falla, vuelve a ejecutar con `--verbose` para ver el stderr real del engine. Modos de fallo habituales: + +| Síntoma | Solución | +|---|---| +| `iii-engine process started` seguido de `did not become ready within 15s` | El engine ha crasheado al arrancar — reejecuta con `--verbose` y revisa stderr | +| `Could not start iii-engine` | Ni `iii.exe` ni Docker están instalados. Ver Opción A o B | +| Conflicto de puerto | `netstat -ano \| findstr :3111` para ver qué está vinculado, mátalo o usa `--port ` | +| Se omite el fallback a Docker aunque Docker esté instalado | Asegúrate de que Docker Desktop esté efectivamente en ejecución (icono en la bandeja del sistema) | + +> Nota: el **motor** iii es un binario preconstruido, no un crate de cargo — no intentes instalarlo con `cargo install`. (Los **SDK** de iii sí están publicados en crates.io, npm y PyPI, pero agentmemory no los necesita.) Métodos de instalación del motor soportados, todos fijados a v0.11.2: el binario preconstruido v0.11.2 de arriba, el script de instalación `sh` upstream **con el pin de versión** `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux) y la imagen Docker `iiidev/iii:0.11.2`. Un simple `install.sh | sh` instala el motor **más reciente**, que agentmemory no soporta — pasa siempre `VERSION=0.11.2`. Lo más fácil de todo: simplemente ejecuta `npx @agentmemory/agentmemory`, que obtiene el motor fijado en `~/.agentmemory/bin` por ti. + +--- + +

Deploy

+ +Plantillas de un clic para hosts gestionados. Cada una incluye un +Dockerfile autocontenido que descarga `@agentmemory/agentmemory` desde npm +y copia el binario del iii engine desde la imagen oficial `iiidev/iii` de +Docker Hub — no se requiere una imagen preconstruida de agentmemory. El +almacenamiento persistente se monta en `/data`; el entrypoint del primer +arranque sobrescribe la configuración iii empaquetada por npm (que se +enlaza a `127.0.0.1`) por una afinada para despliegue que se enlaza a +`0.0.0.0` y usa rutas absolutas `/data`, genera el secreto HMAC y +baja privilegios de `root` a `node` con `gosu` antes de hacer exec +del CLI de agentmemory. + +

+ Deploy to fly.io + Deploy to Railway +

+ +El botón de despliegue de un clic de Render requiere un `render.yaml` en la raíz del repo, que mantenemos limpio a propósito. Usa el flujo Render Blueprint documentado en [`deploy/render/`](../deploy/render/README.md) para apuntar al blueprint del repo manualmente. + +Los detalles completos de configuración (captura HMAC, túnel SSH del visor, rotación, backup, mínimos de coste) están en [`deploy/`](../deploy/README.md): + +- [`deploy/fly`](../deploy/fly/README.md) — máquina única con `auto_stop_machines = "stop"`; más barato en idle. +- [`deploy/railway`](../deploy/railway/README.md) — tarifa plana del plan Hobby, volumen en el dashboard. +- [`deploy/render`](../deploy/render/README.md) — flujo Blueprint, snapshots automáticos de disco en planes de pago. +- [`deploy/coolify`](../deploy/coolify/README.md) — self-hosted en tu propio VPS vía [Coolify](https://coolify.io/self-hosted); misma stack Docker Compose, tú eres dueño del host y los datos. + +Solo se publica el puerto `3111`. El visor en `3113` permanece enlazado a loopback dentro del contenedor — el README de cada plantilla documenta el patrón de túnel SSH para alcanzarlo. + +--- + +

Por qué agentmemory

+ +Todo agente de codificación olvida todo al terminar la sesión. Pierdes los primeros 5 minutos de cada sesión re-explicando tu stack. agentmemory corre en segundo plano y lo elimina por completo. + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### Frente a la memoria integrada del agente + +Todo agente de codificación con IA viene con memoria integrada — Claude Code tiene `MEMORY.md`, Cursor tiene notepads, Cline tiene memory bank. Funcionan como notas adhesivas. agentmemory es la base de datos buscable que hay detrás de esas notas adhesivas. + +| | Integrada (CLAUDE.md) | agentmemory | +|---|---|---| +| Escala | tope de 200 líneas | Ilimitado | +| Búsqueda | Carga todo en contexto | BM25 + vector + graph (solo top-K) | +| Coste en tokens | 22K+ con 240 observaciones | ~1.900 tokens (92% menos) | +| Cross-agent | Ficheros por agente | MCP + REST (cualquier agente) | +| Coordinación | Ninguna | Leases, signals, actions, routines | +| Observabilidad | Lectura manual de ficheros | Visor en tiempo real en :3113 | + +--- + +

Cómo funciona

+ +### Pipeline de memoria + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### Consolidación de memoria en 4 niveles + +Inspirada en cómo el cerebro humano procesa la memoria — no muy diferente de la consolidación del sueño. + +| Nivel | Qué | Analogía | +|------|------|---------| +| **Working** | Observaciones crudas a partir del uso de tools | Memoria a corto plazo | +| **Episodic** | Resúmenes de sesión comprimidos | "Qué pasó" | +| **Semantic** | Hechos y patrones extraídos | "Lo que sé" | +| **Procedural** | Workflows y patrones de decisión | "Cómo hacerlo" | + +Las memorias decaen con el tiempo (curva de Ebbinghaus). Las memorias accedidas con frecuencia se refuerzan. Las memorias obsoletas se evictan automáticamente. Las contradicciones se detectan y resuelven. + +### Qué se captura + +| Hook | Captura | +|------|----------| +| `SessionStart` | Ruta de proyecto, ID de sesión | +| `UserPromptSubmit` | Prompts del usuario (con filtro de privacidad) | +| `PreToolUse` | Patrones de acceso a ficheros + contexto enriquecido | +| `PostToolUse` | Nombre de la tool, entrada, salida | +| `PostToolUseFailure` | Contexto del error | +| `PreCompact` | Re-inyecta memoria antes de la compactación | +| `SubagentStart/Stop` | Ciclo de vida de sub-agentes | +| `Stop` | Resumen de fin de sesión | +| `SessionEnd` | Marcador de sesión completa | + +### Capacidades clave + +| Capacidad | Descripción | +|---|---| +| **Captura automática** | Cada uso de tool registrado vía hooks — esfuerzo manual cero | +| **Búsqueda semántica** | BM25 + vector + grafo de conocimiento con fusión RRF | +| **Evolución de memoria** | Versionado, supersesión, grafos de relaciones | +| **Auto-olvido** | Expiración por TTL, detección de contradicciones, evicción por importancia | +| **Privacy first** | API keys, secretos y etiquetas `` se eliminan antes del almacenado | +| **Self-healing** | Circuit breaker, cadena de fallback de proveedores, monitorización de salud | +| **Puente Claude** | Sincronización bidireccional con MEMORY.md | +| **Grafo de conocimiento** | Extracción de entidades + recorrido BFS | +| **Memoria de equipo** | Espacios compartidos y privados con namespace por miembro | +| **Provenance de citas** | Traza cualquier memoria de vuelta a las observaciones origen | +| **Snapshots de Git** | Versiona, revierte y diffea el estado de memoria | + +--- + + + +Recuperación de triple stream combinando tres señales: + +| Stream | Qué hace | Cuándo | +|---|---|---| +| **BM25** | Coincidencia por palabras con stemming y expansión de sinónimos | Siempre activo | +| **Vector** | Similitud coseno sobre embeddings densos | Proveedor de embeddings configurado | +| **Graph** | Recorrido del grafo de conocimiento vía coincidencia de entidades | Entidades detectadas en la consulta | + +Fusionado con Reciprocal Rank Fusion (RRF, k=60) y diversificado por sesión (máximo 3 resultados por sesión). + +BM25 tokeniza griego, cirílico, hebreo, árabe y latín con tildes de serie. Para memorias en chino / japonés / coreano, instala los segmentadores opcionales (`npm install @node-rs/jieba tiny-segmenter`) para partir los runs CJK en tokens a nivel de palabra; sin ellos, agentmemory hace soft-fallback a tokenización por run completo y muestra una pista única en stderr. + +### Proveedores de embedding + +agentmemory autodetecta tu proveedor. Para mejores resultados, instala embeddings locales (gratis): + +```bash +npm install @xenova/transformers +``` + +| Proveedor | Modelo | Coste | Notas | +|---|---|---|---| +| **Local (recomendado)** | `all-MiniLM-L6-v2` | Gratis | Offline, +8pp de recall sobre BM25-only | +| Gemini | `gemini-embedding-001` | Free tier | 100+ idiomas, 768/1536/3072 dims (MRL), entrada de 2048 tokens. Sustituye a `text-embedding-004` ([deprecado, cierre el 14 ene 2026](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | $0.02/1M | Máxima calidad | +| Voyage AI | `voyage-code-3` | De pago | Optimizado para código | +| Cohere | `embed-english-v3.0` | Trial gratis | Uso general | +| OpenRouter | Cualquier modelo | Varía | Proxy multi-modelo | + +--- + +

Servidor MCP

+ +53 tools, 6 recursos, 3 prompts y 4 skills — el toolkit MCP de memoria más completo para cualquier agente. + +> **Shim MCP vs servidor completo:** el paquete publicado `@agentmemory/mcp` es un shim ligero. Expone la superficie completa de 51 tools **solo cuando puede alcanzar un servidor agentmemory en ejecución** vía `AGENTMEMORY_URL` (modo proxy). Sin servidor accesible, el shim cae a un set local de 7 tools (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). La variable de entorno `AGENTMEMORY_TOOLS=core|all` es un flag *del lado del servidor* — definirla en el bloque `env` del shim no tiene efecto. Si ves solo 7 tools en Cursor / OpenCode / Gemini CLI, arranca `npx @agentmemory/agentmemory` (o la stack Docker) y define `AGENTMEMORY_URL=http://localhost:3111`. + +### 51 Tools + +
+Tools principales (siempre disponibles) + +| Tool | Descripción | +|------|-------------| +| `memory_recall` | Busca observaciones pasadas | +| `memory_compress_file` | Comprime ficheros markdown preservando la estructura | +| `memory_save` | Guarda un insight, decisión o patrón | +| `memory_patterns` | Detecta patrones recurrentes | +| `memory_smart_search` | Búsqueda híbrida semántica + por palabras | +| `memory_file_history` | Observaciones pasadas sobre ficheros concretos | +| `memory_sessions` | Lista sesiones recientes | +| `memory_timeline` | Observaciones cronológicas | +| `memory_profile` | Perfil de proyecto (conceptos, ficheros, patrones) | +| `memory_export` | Exporta todos los datos de memoria | +| `memory_relations` | Consulta el grafo de relaciones | + +
+ +
+Tools extendidas (51 en total — define AGENTMEMORY_TOOLS=all) + +| Tool | Descripción | +|------|-------------| +| `memory_patterns` | Detecta patrones recurrentes | +| `memory_timeline` | Observaciones cronológicas | +| `memory_relations` | Consulta el grafo de relaciones | +| `memory_graph_query` | Recorrido del grafo de conocimiento | +| `memory_consolidate` | Ejecuta la consolidación de 4 niveles | +| `memory_claude_bridge_sync` | Sincroniza con MEMORY.md | +| `memory_team_share` | Comparte con miembros del equipo | +| `memory_team_feed` | Elementos compartidos recientes | +| `memory_audit` | Pista de auditoría de operaciones | +| `memory_governance_delete` | Borrado con pista de auditoría | +| `memory_snapshot_create` | Snapshot versionado en Git | +| `memory_action_create` | Crea ítems de trabajo con dependencias | +| `memory_action_update` | Actualiza estado de una action | +| `memory_frontier` | Actions desbloqueadas, ordenadas por prioridad | +| `memory_next` | La única acción más importante a continuación | +| `memory_lease` | Leases exclusivos de actions (multiagente) | +| `memory_routine_run` | Instancia rutinas de workflow | +| `memory_signal_send` | Mensajería entre agentes | +| `memory_signal_read` | Lee mensajes con acuse de recibo | +| `memory_checkpoint` | Gates de condiciones externas | +| `memory_mesh_sync` | Sincronización P2P entre instancias | +| `memory_sentinel_create` | Watchers dirigidos por eventos | +| `memory_sentinel_trigger` | Dispara sentinels desde fuera | +| `memory_sketch_create` | Grafos de actions efímeros | +| `memory_sketch_promote` | Promociona a permanente | +| `memory_crystallize` | Compacta cadenas de actions | +| `memory_diagnose` | Health checks | +| `memory_heal` | Repara automáticamente estado atascado | +| `memory_facet_tag` | Tags dimension:value | +| `memory_facet_query` | Consulta por tags de facet | +| `memory_verify` | Traza provenance | + +
+ +### 6 Recursos · 3 Prompts · 4 Skills + +| Tipo | Nombre | Descripción | +|------|------|-------------| +| Resource | `agentmemory://status` | Salud, conteo de sesiones, conteo de memorias | +| Resource | `agentmemory://project/{name}/profile` | Inteligencia por proyecto | +| Resource | `agentmemory://memories/latest` | Las 10 memorias activas más recientes | +| Resource | `agentmemory://graph/stats` | Estadísticas del grafo de conocimiento | +| Prompt | `recall_context` | Búsqueda + devuelve mensajes de contexto | +| Prompt | `session_handoff` | Datos de traspaso entre agentes | +| Prompt | `detect_patterns` | Analiza patrones recurrentes | +| Skill | `/recall` | Busca en memoria | +| Skill | `/remember` | Guarda en memoria a largo plazo | +| Skill | `/session-history` | Resúmenes recientes de sesiones | +| Skill | `/forget` | Borra observaciones/sesiones | + +### MCP standalone + +Ejecútalo sin el servidor completo — para cualquier cliente MCP. Cualquiera de estos funciona: + +```bash +npx -y @agentmemory/agentmemory mcp # canonical (always available) +npx -y @agentmemory/mcp # shim package alias +``` + +O añádelo a la configuración MCP de tu agente: + +La mayoría de los agentes (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +Fusiona la entrada `agentmemory` en el objeto `mcpServers` existente del host en lugar de reemplazar el fichero. Para clientes en sandbox que no pueden alcanzar el `localhost` del host, añade `"AGENTMEMORY_FORCE_PROXY": "1"` al bloque env y define `AGENTMEMORY_URL` a una ruta a la que el sandbox sí pueda llegar. + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Copia el fichero del plugin desde el repo: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Visor en tiempo real

+ +Se inicia automáticamente en el puerto `3113`. Stream de observaciones en vivo, explorador de sesiones, navegador de memoria, visualización del grafo de conocimiento y dashboard de salud. + +```bash +open http://localhost:3113 +``` + +El servidor del visor se enlaza a `127.0.0.1` por defecto. El endpoint servido por REST `/agentmemory/viewer` sigue las reglas habituales de bearer-token `AGENTMEMORY_SECRET`. Las cabeceras CSP usan un nonce de script por respuesta y desactivan los atributos handler inline (`script-src-attr 'none'`). + +--- + +

iii Console

+ +El visor en `:3113` muestra lo que tu agente **recordó**. La [iii console](https://iii.dev/docs/console) muestra lo que tu agente **hizo** — cada operación de memoria como una traza OpenTelemetry, cada entrada KV editable, cada función invocable, cada stream tappable. Dos ventanas sobre la misma memoria: una con forma de producto, otra con forma de motor. + +Mira cómo se dispara `memory_smart_search` y observa el escaneo BM25 → consulta de embedding → fusión RRF → reranker como un waterfall. Edita un temporizador de consolidación atascado en el navegador KV. Reproduce un hook `PostToolUse` con un payload ajustado. Fija el stream WebSocket y mira cómo aterrizan las observaciones en vivo. + +agentmemory ofrece esto gratis porque cada función, trigger, scope de estado y stream es un primitivo de iii — nada custom, nada que instrumentar. + +

+ Página Workers de iii console — workers conectados incluyendo instancias de agentmemory con conteo de funciones en vivo y metadatos de runtime +
+ Página Workers: cada worker conectado — incluida agentmemory — con PID, conteo de funciones, runtime y last-seen. +

+ +**Ya instalada.** La console se incluye con `iii` — sin instalador aparte. + +**Lánzala junto a agentmemory:** + +```bash +# agentmemory viewer holds port 3113, so run the console on 3114. +# Engine REST (3111), WebSocket (3112), and bridge (49134) defaults match agentmemory. +iii console --port 3114 +``` + +Luego abre `http://localhost:3114`. Añade `--enable-flow` para la página experimental de grafo de arquitectura. + +Sobrescribe endpoints del engine solo si los has movido: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**Qué puedes hacer desde la console:** + +| Página | Úsala para | +|------|-----------| +| **Workers** | Ver todos los workers conectados y sus métricas en vivo — incluyendo el propio worker de agentmemory. | +| **Functions** | Invocar cualquier función de agentmemory directamente con un payload JSON — útil para probar `memory.recall`, `memory.consolidate`, `graph.query` sin cablear un cliente. | +| **Triggers** | Reproducir triggers HTTP, cron, event y state — disparar manualmente el cron de consolidación, reintentar una ruta HTTP, emitir un cambio de estado. | +| **States** | Navegador KV con CRUD completo — sesiones, slots de memoria, temporizadores del ciclo de vida, índice de embeddings — edita valores in-place. | +| **Streams** | Monitor WebSocket en vivo para escrituras de memoria, eventos de hooks y actualizaciones de observaciones a medida que fluyen por los streams de iii. | +| **Queues** | Topics de cola duraderas + gestión de dead-letter. Reproduce o descarta jobs fallidos de embedding / compresión. | +| **Traces** | Vistas OpenTelemetry waterfall / flame / desglose por servicio. Filtra por `trace_id` para ver exactamente qué funciones, llamadas a BD y peticiones de embedding produjo un único `memory.search`. | +| **Logs** | Logs OTEL estructurados, filtrados y correlados con trace/span IDs. | +| **Config** | Configuración de runtime — ve exactamente con qué workers, proveedores y puertos está ejecutando tu engine. | +| **Flow** | (Opcional, `--enable-flow`) Grafo de arquitectura interactivo de cada worker, trigger y stream. | + +

+ Vista de waterfall de trazas de iii console mostrando duración por span +
+ Traces: waterfall / flame / desglose por servicio para cada operación de memoria. +

+ +**Las trazas ya están activas:** + +`iii-config.yaml` se sirve con el worker `iii-observability` habilitado (`exporter: memory`, `sampling_ratio: 1.0`, métricas + logs). No se necesita configuración adicional — desde el momento en que agentmemory arranca, cada operación de memoria emite una traza-span y un log estructurado que la console puede leer. + +Si quieres exportar a Jaeger/Honeycomb/Grafana Tempo en su lugar, cambia `exporter: memory` por `exporter: otlp` y define el endpoint del collector según la documentación de observabilidad de iii. + +> **Aviso:** la console en sí no impone auth — mantenla enlazada a `127.0.0.1` (por defecto) y nunca la expongas públicamente. + +--- + +

Powered by iii

+ +agentmemory **ya es una instancia [iii](https://iii.dev) en ejecución**. Funciones, triggers, estado KV, streams, trazas OTEL — todo son primitivos de iii. No has instalado Postgres, Redis, Express, pm2 ni Prometheus, porque iii los reemplaza. + +Eso significa que un comando más extiende agentmemory con una capacidad completamente nueva. + +### Extiende agentmemory con un comando + +```bash +iii worker add iii-pubsub # fan memory writes out to every connected instance +iii worker add iii-cron # scheduled consolidation, decay sweeps, snapshot rotation +iii worker add iii-queue # durable retries for embedding + compression jobs +iii worker add iii-observability # OTEL traces on every memory op (default on) +iii worker add iii-sandbox # run recalled code inside an isolated microVM +iii worker add iii-database # swap in a SQL-backed state adapter +iii worker add mcp # generic MCP host alongside the agentmemory MCP +``` + +Cada `iii worker add` registra nuevas funciones y triggers en el mismo engine en el que agentmemory ya está corriendo. El visor y la console los detectan al instante — sin recargar, sin nueva integración, sin nuevo contenedor. + +| `iii worker add` | Qué obtienes encima de agentmemory | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | Memoria multi-instancia: cada `remember` se difunde, cada `search` lee la unión | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Ciclo de vida programado — consolidación nocturna, snapshots semanales, decaimiento en un reloj fijo | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | Reintentos duraderos: los jobs de embedding + compresión fallidos sobreviven al reinicio, sin observaciones perdidas | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | Trazas OTEL, métricas y logs en cada función — cableado en `iii-config.yaml` desde el primer día | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | El código salido de `memory_recall` corre dentro de una VM desechable, no en tu shell | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | Adaptador de estado respaldado por SQL cuando te quedas pequeño con el KV in-memory por defecto | +| [`mcp`](https://workers.iii.dev/workers/mcp) | Levanta servidores MCP adicionales junto al MCP de agentmemory, compartiendo el mismo engine | + +Registro completo: [workers.iii.dev](https://workers.iii.dev). Cada worker allí se compone a través de los mismos primitivos que usa agentmemory — y el agentmemory que ya tienes es uno de ellos. + +### Qué reemplaza iii + +| Stack tradicional | agentmemory usa | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + índice vectorial in-memory | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | Supervisión de workers del iii engine | +| Prometheus / Grafana | iii OTEL + monitor de salud | +| Sistemas de plugins propios | `iii worker add ` | + +**118 ficheros de código · ~21.800 LOC · 950+ tests · 123 funciones · 34 scopes KV** — todo sobre tres primitivos. No hay `agentmemory plugin install`. El sistema de plugins es iii mismo. + +--- + +

Configuración

+ +### Proveedores de LLM + +agentmemory autodetecta desde tu entorno. Por defecto no se hacen llamadas LLM a menos que configures un proveedor o aceptes explícitamente el fallback de suscripción de Claude. + +| Proveedor | Configuración | Notas | +|----------|--------|-------| +| **No-op (por defecto)** | Sin configuración | Compresión/resumen vía LLM DESACTIVADA. La compresión sintética BM25 + recall siguen funcionando. Mira `AGENTMEMORY_ALLOW_AGENT_SDK` más abajo si dependías del fallback de suscripción de Claude. | +| Anthropic API | `ANTHROPIC_API_KEY` | Facturación por token | +| MiniMax | `MINIMAX_API_KEY` | Compatible con Anthropic | +| Gemini | `GEMINI_API_KEY` | También habilita embeddings | +| OpenRouter | `OPENROUTER_API_KEY` | Cualquier modelo | +| Claude subscription fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Solo opt-in. Lanza sesiones de `@anthropic-ai/claude-agent-sdk` — solía causar recursión sin límite en el Stop-hook, por eso ya no es el comportamiento por defecto. | + +### Selección de modelo con conciencia de coste + +La compresión en background corre en cada observación, así que la elección de modelo cambia el gasto mensual de forma significativa. Datos de carga capturados: 635 peticiones / 888K tokens / 35 horas de uso activo, sobre tres modelos de OpenRouter con precios del 2026-05-23. + +| Tier | Modelo | Input / 1M | Output / 1M | Coste para las 35h capturadas | Notas | +|------|-------|------------|-------------|---------------------------|-------| +| Recomendado | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | Calidad de compresión + resumen sólida a ~10× menos coste que Sonnet. | +| Recomendado | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | Más antiguo pero aún correcto para cargas solo de compresión. | +| Recomendado | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | Buen razonamiento de código si tus sesiones son muy code-centric. | +| Premium | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | Alta calidad pero caro para trabajo de background siempre activo. | +| Premium | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | Tier similar a Sonnet. | +| Evitar | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | Modelo de reasoning; sobrecoste enorme para compresión. | + +agentmemory imprime un aviso en runtime cuando `OPENROUTER_MODEL` coincide con un patrón de tier premium. Define `AGENTMEMORY_SUPPRESS_COST_WARNING=1` para silenciarlo una vez que hayas tomado una decisión informada. + +Trade-off de calidad vs coste en trabajo de memoria: la compresión es una tarea de resumen con un listón de calidad relativamente flexible (quien re-lee el resumen es el agente, no el usuario). DeepSeek-V4-Pro / Qwen3-Coder se quedan dentro del error de redondeo respecto a Sonnet en esta tarea, costando ~10× menos. Reserva los modelos de tier premium para las consultas que leas directamente. + +Fuentes: [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/). + +### Memoria multiagente (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +En montajes multiagente donde varios roles comparten un servidor agentmemory (architect / developer / reviewer / researcher / support-agent), `AGENT_ID` etiqueta cada escritura con el rol que la hizo. `AGENTMEMORY_AGENT_SCOPE` controla si el recall filtra por esa etiqueta. + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" +``` + +Dos modos: + +| Modo | Etiqueta escrituras | Filtra recall | Cuándo usarlo | +|------|------------|---------------|-------------| +| `shared` (por defecto) | sí | no | Contexto cross-agent con pista de auditoría. El architect puede ver lo que el developer apuntó, pero cada fila registra quién lo dijo. | +| `isolated` | sí | sí | Separación estricta. El architect nunca ve observaciones / memorias / sesiones del developer. | + +Qué se etiqueta cuando `AGENT_ID` está definido: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. El rol fluye `api::session::start` → `mem::observe` → `mem::compress` → KV. + +Qué se filtra en modo isolated: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Cada endpoint acepta `?agentId=` para sobreescribir por petición, y `?agentId=*` para optar por salir del scope del entorno por completo. `/memories` también acepta `?includeOrphans=true` para sacar memorias previas a AGENT_ID cuyo `agentId` es undefined. + +Sobrescritura por llamada en la capa SDK / REST: cada endpoint que muta (`/session/start`, `/remember`) acepta un campo `agentId` en el body que gana frente al entorno. Útil para runtimes que enrutan muchos roles a un único proceso de servidor. + +Cuando `AGENT_ID` no está definido, la memoria permanece sin scope (comportamiento legacy, sin etiquetas, sin filtros). + +### Puertos + +agentmemory + iii-engine enlazan cuatro puertos por defecto. Si un reinicio falla con `port in use`, esta tabla te dice qué proceso buscar. + +| Puerto | Proceso | Propósito | Override por env | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | Worker de streams interno (consumido por agentmemory + visor) | `III_STREAMS_PORT` | +| `3113` | agentmemory | Visor en tiempo real (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — los workers se registran aquí, la telemetría OTel fluye por encima | `III_ENGINE_URL` (URL completa, por defecto `ws://localhost:49134`) | + +Limpieza de procesos zombi cuando los puertos quedan ocupados tras una ejecución crasheada: + +```bash +# macOS / Linux — find whatever is on each port and kill it +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` recoge limpiamente tanto el worker como el pidfile del engine en un shutdown graceful. La limpieza manual de arriba solo aplica al caso post-crash en el que no queda ningún pidfile. + +### Fichero de configuración + +Coloca la configuración de runtime de agentmemory en `~/.agentmemory/.env` en lugar de exportar variables en cada shell. Si el visor muestra una pista de setup tipo `export ANTHROPIC_API_KEY=...`, cópiala a este fichero como `ANTHROPIC_API_KEY=...` sin el prefijo `export`, y reinicia agentmemory. + +Las variables de entorno del proceso siguen funcionando y tienen prioridad sobre los valores del fichero. + +En Windows, el mismo fichero vive en `%USERPROFILE%\.agentmemory\.env`: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +Para probar con una suscripción Claude Code Pro/Max en lugar de una API key, acepta opt-in explícito: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +Activa graph o consolidation en el mismo fichero si las quieres: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### Variables de entorno + +Crea `~/.agentmemory/.env`: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +124 endpoints en el puerto `3111`. La REST API se enlaza a `127.0.0.1` por defecto. Los endpoints protegidos requieren `Authorization: Bearer ` cuando `AGENTMEMORY_SECRET` está definido, y los endpoints de mesh sync requieren `AGENTMEMORY_SECRET` en ambos peers. + +
+Endpoints principales + +| Method | Path | Descripción | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | Health check (siempre público) | +| `POST` | `/agentmemory/session/start` | Inicia sesión + obtiene contexto | +| `POST` | `/agentmemory/session/end` | Finaliza sesión | +| `POST` | `/agentmemory/observe` | Captura observación | +| `POST` | `/agentmemory/smart-search` | Búsqueda híbrida | +| `POST` | `/agentmemory/context` | Genera contexto | +| `POST` | `/agentmemory/remember` | Guarda en memoria a largo plazo | +| `POST` | `/agentmemory/forget` | Borra observaciones | +| `POST` | `/agentmemory/enrich` | Contexto de fichero + memorias + bugs | +| `GET` | `/agentmemory/profile` | Perfil de proyecto | +| `GET` | `/agentmemory/export` | Exporta todos los datos | +| `POST` | `/agentmemory/import` | Importa desde JSON | +| `POST` | `/agentmemory/graph/query` | Consulta del grafo de conocimiento | +| `POST` | `/agentmemory/team/share` | Comparte con el equipo | +| `GET` | `/agentmemory/audit` | Pista de auditoría | + +Lista completa de endpoints: [`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Desarrollo

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 950+ tests +npm run test:integration # API tests (requires running services) +``` + +**Requisitos previos:** Node.js >= 20, [iii-engine](https://iii.dev/docs) o Docker + +

Licencia

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.fr-FR.md b/READMEs/README.fr-FR.md new file mode 100644 index 0000000..b72b503 --- /dev/null +++ b/READMEs/README.fr-FR.md @@ -0,0 +1,1376 @@ +

+ agentmemory — Mémoire persistante pour les agents de codage IA +

+ +

+ + Votre agent de codage se souvient de tout. Fini de tout réexpliquer. + Built on iii engine +
+ Mémoire persistante pour Claude Code, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode et tout client MCP. +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Document de conception : 1200 stars / 172 forks sur le gist +

+ +

+ Le gist étend le motif LLM Wiki de Karpathy avec scoring de confiance, cycle de vie, graphes de connaissances et recherche hybride : agentmemory en est l'implémentation. +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ Démo agentmemory +

+ +

+ Installation • + Démarrage rapide • + Benchmarks • + vs Concurrents • + Agents • + Fonctionnement • + MCP • + Visualiseur • + iii Console • + Powered by iii • + Configuration • + API +

+ +--- + +## Install + +```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 connect claude-code # wire your agent (also: codex, cursor, gemini-cli, ...) +``` + +Ou via `npx` (sans installation) : + +```bash +npx @agentmemory/agentmemory +``` + +À noter — npx met en cache par version. Si un simple `npx @agentmemory/agentmemory` sert une version plus ancienne, forcez la dernière avec `npx -y @agentmemory/agentmemory@latest`, ou videz le cache une fois avec `rm -rf ~/.npm/_npx` (macOS/Linux ; sur Windows, supprimez `%LOCALAPPDATA%\npm-cache\_npx`). Depuis v0.9.16+, la première exécution npx propose une installation globale inline pour que la commande `agentmemory` soit ensuite disponible partout. + +Toutes les options dans [Démarrage rapide](#quick-start) ci-dessous. Câblage spécifique par agent dans [Compatible avec tous les agents](#works-with-every-agent). + +--- + +

Compatible avec tous les agents

+ +agentmemory fonctionne avec tout agent qui prend en charge les hooks, MCP ou l'API REST. Tous les agents partagent le même serveur de mémoire. + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+plugin natif + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+plugin natif + 6 hooks + MCP +
+OpenClaw
+OpenClaw
+plugin natif + MCP +
+Hermes
+Hermes
+plugin natif + MCP +
+pi
+pi
+plugin natif + MCP +
+OpenHuman
+OpenHuman
+backend natif trait Memory +
+Cursor
+Cursor
+serveur MCP +
+Gemini CLI
+Gemini CLI
+serveur MCP +
+OpenCode
+OpenCode
+22 hooks + MCP + plugin +
+Cline
+Cline
+serveur MCP +
+Goose
+Goose
+serveur MCP +
+Kilo Code
+Kilo Code
+serveur MCP +
+Aider
+Aider
+API REST +
+Claude Desktop
+Claude Desktop
+serveur MCP +
+Windsurf
+Windsurf
+serveur MCP +
+Roo Code
+Roo Code
+serveur MCP +
+ +

+ Fonctionne avec n'importe quel agent qui parle MCP ou HTTP. Un seul serveur, des mémoires partagées entre tous. +

+ +--- + +Vous expliquez la même architecture à chaque session. Vous redécouvrez les mêmes bugs. Vous réenseignez les mêmes préférences. La mémoire intégrée (CLAUDE.md, .cursorrules) plafonne à 200 lignes et devient obsolète. agentmemory règle ce problème. Il capture silencieusement ce que fait votre agent, le compresse dans une mémoire interrogeable, puis injecte le bon contexte au démarrage de la session suivante. Une seule commande. Compatible entre agents. + +**Ce qui change :** Session 1, vous mettez en place l'authentification JWT. Session 2, vous demandez une limitation de débit. L'agent sait déjà que votre authentification utilise le middleware jose dans `src/middleware/auth.ts`, que vos tests couvrent la validation des tokens, et que vous avez choisi jose plutôt que jsonwebtoken pour la compatibilité Edge. Pas de réexplication. Pas de copier-coller. L'agent *sait*, point. + +```bash +npx @agentmemory/agentmemory +``` + +> **Nouveau en v0.9.0** — Site d'accueil sur [agent-memory.dev](https://agent-memory.dev), connecteur système de fichiers (`@agentmemory/fs-watcher`), le MCP standalone fait désormais proxy vers le serveur en cours d'exécution afin que les hooks et le visualiseur soient cohérents, politique d'audit codifiée sur tous les chemins de suppression, l'état de santé ne signale plus `memory_critical` sur les petits processus Node. Notes complètes dans [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18). + +--- + +

Benchmarks

+ + + + + + +
+ +### Précision de récupération + +**coding-agent-life-v1** (corpus interne, reproductible en sandbox) + +| Adaptateur | P@5 | R@5 | Taux de hit top-5 | Latence p50 | +|---|---|---|---|---| +| **agentmemory hybrid** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| Référence grep | 0.267 | 0.967 | 15 / 15 | 0 ms | + +100 % de taux de hit top-5. **2,2×** meilleure précision que la référence grep sur entrée identique. Ventilation complète par type : [`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) + +| Système | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| Repli BM25 seul | 86.2% | 94.6% | 71.5% | + + + +### Économies de tokens + +| Approche | Tokens/an | Coût/an | +|---|---|---| +| Coller le contexte complet | 19,5M+ | Impossible (dépasse la fenêtre) | +| Résumé par LLM | ~650K | ~500 $ | +| **agentmemory** | **~170K** | **~10 $** | +| agentmemory + embeddings locaux | ~170K | **0 $** | + +
+ +> Modèle d'embedding : `all-MiniLM-L6-v2` (local, gratuit, aucune clé d'API). Rapports complets : [`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](../benchmark/QUALITY.md), [`benchmark/SCALE.md`](../benchmark/SCALE.md). Comparaison avec les concurrents : [`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory vs mem0, Letta, Khoj, claude-mem, Hippo. + +**Reproduire localement :** [`eval/README.md`](../eval/README.md) — harnais à adaptateurs pluggables pour LongMemEval `_s` (public, 500 questions) + `coding-agent-life-v1` (corpus interne de 15 sessions). Les adaptateurs grep / vectoriel / agentmemory sont scorés côte à côte, sortie NDJSON, scorecards publiés dans [`docs/benchmarks/`](../docs/benchmarks/). + +**À associer à [codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything) et [Graphify](https://github.com/safishamsi/graphify).** Indexation de graphe de code, pipelines de build multi-agents et graphes de connaissances étendus sur docs / PDFs / images / vidéos. agentmemory mémorise le travail ; ces trois projets éclairent le reste de la couche de contexte. Recettes et tableau de routage des questions : [`docs/recipes/pairings.md`](../docs/recipes/pairings.md). + +--- + +

vs Concurrents

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)Intégré (CLAUDE.md)
TypeMoteur de mémoire + serveur MCPAPI de couche mémoireRuntime d'agent completFichier statique
R@5 de récupération95.2%68.5% (LoCoMo)83.2% (LoCoMo)N/A (grep)
Capture automatique12 hooks (zéro effort manuel)Appels add() manuelsL'agent s'édite lui-mêmeÉdition manuelle
RechercheBM25 + Vectoriel + Graphe (fusion RRF)Vectoriel + GrapheVectoriel (archival)Charge tout en contexte
Multi-agentsMCP + REST + leases + signauxAPI (sans coordination)Uniquement dans le runtime LettaFichiers par agent
Verrouillage frameworkAucun (tout client MCP)AucunÉlevé (Letta obligatoire)Format par agent
Dépendances externesAucune (SQLite + iii-engine)Qdrant / pgvectorPostgres + base vectorielleAucune
Cycle de vie mémoireConsolidation à 4 niveaux + décroissance + oubli automatiqueExtraction passiveGérée par l'agentÉlagage manuel
Efficacité en tokens~1 900 tokens/session (10 $/an)Variable selon l'intégrationMémoire centrale dans le contexte22K+ tokens à 240 observations
Visualiseur temps réelOui (port 3113)Tableau de bord cloudTableau de bord cloudNon
Auto-hébergéOui (par défaut)OptionnelOptionnelOui
+ +--- + +

Démarrage rapide

+ +Compatibilité : cette version cible `iii-sdk` stable `^0.11.0` et iii-engine v0.11.x. + +### Essayez en 30 secondes + +```bash +# Terminal 1: start the server +npx @agentmemory/agentmemory + +# Terminal 2: seed sample data and see recall in action +npx @agentmemory/agentmemory demo +``` + +`demo` amorce 3 sessions réalistes (auth JWT, correctif de requêtes N+1, limitation de débit) et lance des recherches sémantiques dessus. Vous verrez le système trouver « N+1 query fix » quand vous cherchez « database performance optimization » — la correspondance par mots-clés en est incapable. + +Ouvrez `http://localhost:3113` pour voir la mémoire se construire en direct. + +### Recommandé : installation globale + +`npx` met en cache par version. Si vous avez lancé `npx @agentmemory/agentmemory@0.9.14` la semaine dernière, un simple `npx @agentmemory/agentmemory` peut servir le 0.9.14 obsolète depuis `~/.npm/_npx/`, pas la dernière version. Installez une fois et la commande `agentmemory` est disponible partout : + +```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 stop # tear it down +agentmemory remove # uninstall everything we created +agentmemory connect claude-code # wire one agent +agentmemory doctor # interactive diagnostics + fix prompts +``` + +À partir de v0.9.16, la première exécution npx propose une installation globale inline — répondez `Y` une fois et c'est réglé. Si vous passez l'étape, repliez sur l'une de ces options pour un fetch frais : + +```bash +npx -y @agentmemory/agentmemory@latest # forces latest from npm (cross-platform) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # macOS/Linux only (POSIX shell) +``` + +Sur Windows / PowerShell, l'équivalent pour vider le cache est `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` — le `npx -y ...@latest` ci-dessus reste l'option multiplateforme. + +### Replay de session + +Chaque session enregistrée par agentmemory est rejouable. Ouvrez le visualiseur, choisissez l'onglet **Replay**, et parcourez la chronologie : prompts, appels d'outils, résultats d'outils et réponses s'affichent comme événements discrets avec play/pause, contrôle de vitesse (0,5×–4×) et raccourcis clavier (espace pour basculer, flèches pour avancer). + +Vous avez déjà d'anciennes transcriptions JSONL Claude Code à importer ? + +```bash +# Import everything under the default ~/.claude/projects +npx @agentmemory/agentmemory import-jsonl + +# Or import a single file +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +Les sessions importées apparaissent dans le sélecteur Replay aux côtés des natives. Sous le capot, chaque entrée passe par les fonctions iii `mem::replay::load`, `mem::replay::sessions` et `mem::replay::import-jsonl` — aucun serveur secondaire. + +### Mise à niveau / Maintenance + +Utilisez la commande de maintenance lorsque vous voulez intentionnellement mettre à jour votre runtime local : + +```bash +npx @agentmemory/agentmemory upgrade +``` + +Avertissement : cette commande modifie l'espace de travail / runtime courant. Elle peut mettre à jour les dépendances JavaScript et tirer l'image Docker épinglée `iiidev/iii:0.11.2`. Elle n'installe jamais un moteur iii non épinglé ou plus récent. + +Détails d'implémentation dans `src/cli.ts` (voir `runUpgrade` autour de la zone `src/cli.ts:544-595`). + +### Claude Code (un seul bloc, à coller) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Claude Code sans installation du plugin (chemin MCP-standalone) + +Si vous câblez le serveur MCP d'agentmemory via `~/.claude.json` directement plutôt que via `/plugin install`, Claude Code ne résout jamais `${CLAUDE_PLUGIN_ROOT}` et vous devez pointer les scripts de hooks vers des chemins absolus dans `~/.claude/settings.json`. Ces chemins embarquent typiquement la version d'agentmemory (par ex. `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), si bien que la mise à niveau suivante casse silencieusement tous les hooks. + +Contournement : + +```bash +agentmemory connect claude-code --with-hooks +``` + +Cela fusionne les mêmes commandes de hooks dans `~/.claude/settings.json` avec des chemins absolus résolus vers le répertoire `plugin/` du paquet `@agentmemory/agentmemory` actuellement installé. Relancez la commande après une mise à niveau d'agentmemory pour rafraîchir les chemins. Les entrées de l'utilisateur dans le même fichier sont préservées ; seules les entrées agentmemory précédentes sont remplacées. Utiliser le chemin `/plugin install` reste l'approche recommandée. +Pour des déploiements distants ou protégés, lancez Claude Code avec `AGENTMEMORY_URL` et `AGENTMEMORY_SECRET` définis. Le plugin transmet les deux valeurs à son serveur MCP intégré ; quand `AGENTMEMORY_URL` est vide, le shim MCP utilise `http://localhost:3111`. + +### Codex CLI (plateforme de plugins Codex) + +```bash +# 1. start the memory server in a separate terminal +npx @agentmemory/agentmemory + +# 2. register the agentmemory marketplace and install the plugin +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Le plugin Codex est livré depuis le même répertoire `plugin/` que le plugin Claude Code. Il enregistre : + +- `@agentmemory/mcp` comme serveur MCP (proxie les 51 outils lorsque `AGENTMEMORY_URL` pointe vers un serveur agentmemory actif ; retombe sur 7 outils en local si aucun serveur n'est accessible) +- 6 hooks de cycle de vie : `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 4 skills : `/recall`, `/remember`, `/session-history`, `/forget` + +Le moteur de hooks de Codex injecte `CLAUDE_PLUGIN_ROOT` dans les sous-processus de hooks (cf. [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)), donc les mêmes scripts de hooks fonctionnent sur les deux hôtes sans duplication. Les événements Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure sont propres à Claude Code et ne sont pas enregistrés pour Codex. + +#### Codex Desktop : hooks de plugin actuellement silencieux (contournement disponible) + +`CodexHooks` et `PluginHooks` sont tous deux stables + activés par défaut dans [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs), mais les builds Codex Desktop actuels ne dispatchent pas les `hooks.json` locaux au plugin ([openai/codex#16430](https://github.com/openai/codex/issues/16430)). Les outils MCP fonctionnent toujours ; seules les observations de cycle de vie manquent. + +En attendant le correctif amont, dupliquez les mêmes commandes de hooks dans le `~/.codex/hooks.json` global : + +```bash +agentmemory connect codex --with-hooks +``` + +Cela ajoute un bloc idempotent à `~/.codex/hooks.json` qui référence des chemins absolus vers les scripts intégrés (pas besoin d'expansion `${CLAUDE_PLUGIN_ROOT}` au scope utilisateur). Relancez la même commande après une mise à niveau d'agentmemory pour rafraîchir les chemins. Les entrées de l'utilisateur dans le même fichier sont préservées ; seules les entrées agentmemory précédentes sont remplacées. + +
+OpenClaw (collez ce prompt) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +Guide complet : [`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent (collez ce prompt) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +Guide complet : [`integrations/hermes/`](../integrations/hermes/) + +
+ +### Autres agents + +Démarrez le serveur de mémoire : `npx @agentmemory/agentmemory` + +L'entrée agentmemory est le **même bloc serveur MCP** pour tous les hôtes utilisant le format `mcpServers` (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw) : + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**Fusionnez cette entrée dans l'objet `mcpServers` existant** du fichier de config de l'hôte — ne remplacez pas le fichier. Si le fichier contient déjà d'autres serveurs, ajoutez `agentmemory` à côté d'eux comme nouvelle clé dans `mcpServers`. Si `mcpServers` est totalement absent, collez le bloc dans `{ "mcpServers": { ... } }`. Les placeholders `${VAR}` héritent de `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` depuis le shell au lancement du serveur MCP — des vars non définies passent des chaînes vides et le shim retombe sur `http://localhost:3111`. Une seule entrée câblée couvre à la fois les déploiements locaux et distants (k8s / reverse-proxy). + +| Agent | Fichier de config | Notes | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | Fusionner dans `mcpServers`. Deeplink en un clic également disponible sur le site web. | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | Fusionner dans `mcpServers`. Redémarrer Claude Desktop après modification. | +| **Cline / Roo Code / Kilo Code** | Paramètres MCP de Cline (Settings UI → MCP Servers → Edit) | Même bloc `mcpServers`. | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Même bloc `mcpServers`. | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (fusion automatique). | +| **OpenClaw** | Config MCP d'OpenClaw | Même bloc `mcpServers`, ou utilisez le [plugin mémoire plus poussé](../integrations/openclaw/). | +| **Codex CLI (MCP seul)** | `.codex/config.toml` | Format TOML : `codex mcp add agentmemory -- npx -y @agentmemory/mcp`, ou ajoutez `[mcp_servers.agentmemory]` à la main. | +| **Codex CLI (plugin complet)** | Marketplace de plugins Codex | `codex plugin marketplace add rohitg00/agentmemory` puis `codex plugin add agentmemory@agentmemory`. Enregistre MCP + 6 hooks de cycle de vie (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skills. Sur Codex Desktop, lancez également `agentmemory connect codex --with-hooks` en attendant que [openai/codex#16430](https://github.com/openai/codex/issues/16430) soit corrigé — les hooks de plugin y sont actuellement silencieux. | +| **OpenCode (MCP seul)** | `opencode.json` | Format différent — clé `mcp` au niveau racine, commande sous forme de tableau : `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. | +| **OpenCode (plugin complet)** | `plugin/opencode/` | 22 hooks de capture automatique couvrant cycle de vie de session, messages, outils, erreurs. Deux commandes slash (`/recall`, `/remember`). Copiez `plugin/opencode/` dans votre workspace OpenCode et ajoutez l'entrée du plugin à `opencode.json`. Voir [`plugin/opencode/README.md`](../plugin/opencode/README.md) pour le tableau complet des hooks et l'analyse des manques. | +| **pi** | `~/.pi/agent/extensions/agentmemory` | Copiez [`integrations/pi`](../integrations/pi/) et redémarrez pi. | +| **Hermes Agent** | `~/.hermes/config.yaml` | Utilisez le [plugin de fournisseur de mémoire plus poussé](../integrations/hermes/) avec `memory.provider: agentmemory`. | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` écrit le bloc `mcpServers` standard. La charge utile des hooks est compatible champ-à-champ avec Claude Code, donc les 12 scripts de hooks existants fonctionnent sans modification — câblez-les via la section `hooks` du même `settings.json`. | +| **Antigravity** (remplace Gemini CLI) | `mcp_config.json` (dans le répertoire User d'Antigravity) | `agentmemory connect antigravity` écrit le bloc `mcpServers` standard. macOS : `~/Library/Application Support/Antigravity/User/`. Linux : `~/.config/Antigravity/User/`. À utiliser après l'arrêt de Gemini CLI au 2026-06-18. | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` écrit la config au niveau utilisateur. Les overrides de workspace vont dans `.kiro/settings/mcp.json` à côté de votre code. | +| **Goose** | UI des paramètres MCP de Goose | Même bloc `mcpServers`. | +| **Aider** | n/a | Parlez directement à l'API REST : `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. | +| **Tout agent (32+)** | n/a | `npx skillkit install agentmemory` détecte l'hôte automatiquement et fusionne. | + +**Clients MCP en sandbox** (Flatpak / Snap / conteneurs restrictifs) qui ne peuvent pas joindre le `localhost` de l'hôte : définissez également `"AGENTMEMORY_FORCE_PROXY": "1"` dans le bloc `env` et pointez `AGENTMEMORY_URL` vers une route que la sandbox peut effectivement atteindre (par ex. votre IP LAN). + +### Accès programmatique (Python / Rust / Node) + +agentmemory enregistre ses opérations principales en tant que fonctions iii (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`). N'importe quel langage doté d'un SDK iii peut les appeler directement sur `ws://localhost:49134` — pas besoin de client REST séparé par langage. + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +Exemple complet : [`examples/python/`](../examples/python/) (quickstart + flux observation/recall). REST sur `:3111` reste disponible pour les hôtes sans runtime iii. + +### Depuis les sources + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +Cela démarre agentmemory avec un `iii-engine` local si `iii` est déjà installé, ou retombe sur Docker Compose si Docker est disponible. REST, streams et visualiseur se lient à `127.0.0.1` par défaut. + +Installer `iii-engine` manuellement. **agentmemory épingle actuellement `iii-engine` à `v0.11.2`** — `v0.11.6` introduit un nouveau modèle de sandboxing systématique via `iii worker add` pour lequel agentmemory n'a pas encore été refactorisé. L'épinglage sera levé une fois la refonte effectuée. Surchargez avec `AGENTMEMORY_III_VERSION=` si vous avez migré au modèle sandbox manuellement. + +- **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 :** remplacez `aarch64-apple-darwin` par `x86_64-apple-darwin` +- **Linux x64 :** remplacez par `x86_64-unknown-linux-gnu` +- **Linux arm64 :** remplacez par `aarch64-unknown-linux-gnu` +- **Windows :** téléchargez `iii-x86_64-pc-windows-msvc.zip` depuis [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2), extrayez `iii.exe`, ajoutez-le au PATH + +Ou utilisez Docker (le `docker-compose.yml` fourni tire `iiidev/iii:0.11.2`). Documentation complète : [iii.dev/docs](https://iii.dev/docs). + +### Windows + +agentmemory tourne sur Windows 10/11, mais le paquet Node.js seul ne suffit pas — il vous faut aussi le runtime `iii-engine` (un binaire natif séparé) comme processus en arrière-plan. L'installeur amont officiel est un script `sh` et il n'existe à ce jour ni installeur PowerShell ni paquet scoop/winget, donc les utilisateurs Windows ont deux chemins : + +**Option A — Binaire Windows précompilé (recommandé) :** + +```powershell +# 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 in your browser +# (we pin to v0.11.2 until agentmemory refactors for the new sandbox +# model that engine v0.11.6+ requires) +# 2. Download iii-x86_64-pc-windows-msvc.zip +# (or iii-aarch64-pc-windows-msvc.zip if you're on an ARM machine) +# 3. Extract iii.exe somewhere on PATH, or place it at: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory checks that location automatically) +# 4. Verify: +iii --version +# Should print: 0.11.2 + +# 5. Then run agentmemory as usual: +npx -y @agentmemory/agentmemory +``` + +**Option B — Docker Desktop :** + +```powershell +# 1. Install Docker Desktop for Windows +# 2. Start Docker Desktop and make sure the engine is running +# 3. Run agentmemory — it will auto-start the bundled compose file: +npx -y @agentmemory/agentmemory +``` + +**Option C — MCP standalone uniquement (sans moteur) :** si vous n'avez besoin que des outils MCP pour votre agent et pas de l'API REST, du visualiseur ou des jobs cron, sautez le moteur : + +```powershell +npx -y @agentmemory/agentmemory mcp +# or via the shim package: +npx -y @agentmemory/mcp +``` + +**Diagnostics pour Windows :** si `npx @agentmemory/agentmemory` échoue, relancez avec `--verbose` pour voir le stderr réel du moteur. Modes de défaillance courants : + +| Symptôme | Correctif | +|---|---| +| `iii-engine process started` puis `did not become ready within 15s` | Le moteur a planté au démarrage — relancez avec `--verbose`, vérifiez stderr | +| `Could not start iii-engine` | Ni `iii.exe` ni Docker installés. Voir les options A ou B ci-dessus | +| Conflit de port | `netstat -ano \| findstr :3111` pour voir ce qui est lié, puis tuez-le ou utilisez `--port ` | +| Fallback Docker ignoré bien que Docker soit installé | Assurez-vous que Docker Desktop tourne effectivement (icône de la barre d'état système) | + +> Note : le **moteur** iii est un binaire précompilé, pas un crate cargo — n'essayez pas de l'installer avec `cargo install`. (Les **SDK** iii sont bien publiés sur crates.io, npm et PyPI, mais agentmemory n'en a pas besoin.) Méthodes d'installation du moteur supportées, toutes épinglées à v0.11.2 : le binaire précompilé v0.11.2 ci-dessus, le script d'installation `sh` amont **avec l'épingle de version** `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux) et l'image Docker `iiidev/iii:0.11.2`. Un simple `install.sh | sh` installe le moteur **le plus récent**, que agentmemory ne supporte pas — passez toujours `VERSION=0.11.2`. Le plus simple de tout : exécutez simplement `npx @agentmemory/agentmemory`, qui récupère le moteur épinglé dans `~/.agentmemory/bin` pour vous. + +--- + +

Déploiement

+ +Templates en un clic pour les hébergeurs managés. Chacun livre un Dockerfile +autonome qui récupère `@agentmemory/agentmemory` depuis npm et copie le +binaire iii engine depuis l'image officielle `iiidev/iii` du Docker +Hub — pas d'image agentmemory précompilée requise. Le stockage +persistant se monte sur `/data` ; le point d'entrée au premier +démarrage réécrit la config iii livrée par npm (qui se lie à +`127.0.0.1`) par une version réglée pour le déploiement qui se lie à +`0.0.0.0` et utilise des chemins absolus `/data`, génère le secret HMAC, +puis abaisse les privilèges de `root` à `node` via `gosu` avant +d'exec'er la CLI agentmemory. + +

+ Deploy to fly.io + Deploy to Railway +

+ +Le bouton de déploiement en un clic de Render exige un `render.yaml` à la racine du dépôt, que nous gardons délibérément propre. Utilisez le flux Render Blueprint documenté dans [`deploy/render/`](./deploy/render/README.md) pour pointer manuellement vers le blueprint dans le dépôt. + +Détails complets de configuration (capture HMAC, tunnel SSH du visualiseur, rotation, sauvegarde, plafonds de coût) dans [`deploy/`](./deploy/README.md) : + +- [`deploy/fly`](./deploy/fly/README.md) — machine unique avec + `auto_stop_machines = "stop"` ; le moins cher à l'arrêt. +- [`deploy/railway`](./deploy/railway/README.md) — forfait Hobby à tarif fixe, + volume dans le tableau de bord. +- [`deploy/render`](./deploy/render/README.md) — flux Blueprint, + snapshots disque automatiques sur les forfaits payants. +- [`deploy/coolify`](./deploy/coolify/README.md) — auto-hébergé sur votre + propre VPS via [Coolify](https://coolify.io/self-hosted) ; même stack + Docker Compose, vous possédez l'hôte et les données. + +Seul le port `3111` est publié. Le visualiseur sur `3113` reste lié à la +boucle locale dans le conteneur — chaque README de template documente +le motif tunnel SSH pour y accéder. + +--- + +

Pourquoi agentmemory

+ +Chaque agent de codage oublie tout quand la session se termine. Vous perdez les 5 premières minutes de chaque session à réexpliquer votre stack. agentmemory tourne en arrière-plan et élimine totalement cette perte. + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### vs mémoire d'agent intégrée + +Chaque agent de codage IA est livré avec une mémoire intégrée — Claude Code a `MEMORY.md`, Cursor a des notepads, Cline a memory bank. Cela fonctionne comme des post-it. agentmemory est la base de données interrogeable derrière les post-it. + +| | Intégrée (CLAUDE.md) | agentmemory | +|---|---|---| +| Échelle | Plafond de 200 lignes | Illimitée | +| Recherche | Charge tout en contexte | BM25 + vecteur + graphe (top-K seul) | +| Coût en tokens | 22K+ à 240 observations | ~1 900 tokens (92 % de moins) | +| Inter-agents | Fichiers par agent | MCP + REST (n'importe quel agent) | +| Coordination | Aucune | Leases, signaux, actions, routines | +| Observabilité | Lire les fichiers à la main | Visualiseur temps réel sur :3113 | + +--- + +

Fonctionnement

+ +### Pipeline mémoire + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### Consolidation mémoire à 4 niveaux + +Inspirée de la façon dont le cerveau humain traite la mémoire — pas si éloignée de la consolidation pendant le sommeil. + +| Niveau | Quoi | Analogie | +|------|------|---------| +| **Working** | Observations brutes issues de l'usage des outils | Mémoire à court terme | +| **Episodic** | Résumés de session compressés | « Ce qui s'est passé » | +| **Semantic** | Faits et motifs extraits | « Ce que je sais » | +| **Procedural** | Workflows et motifs de décision | « Comment faire » | + +Les mémoires décroissent dans le temps (courbe d'Ebbinghaus). Les mémoires fréquemment consultées se renforcent. Les mémoires obsolètes sont évincées automatiquement. Les contradictions sont détectées et résolues. + +### Ce qui est capturé + +| Hook | Capture | +|------|----------| +| `SessionStart` | Chemin de projet, ID de session | +| `UserPromptSubmit` | Prompts utilisateur (filtrés pour la vie privée) | +| `PreToolUse` | Motifs d'accès fichier + contexte enrichi | +| `PostToolUse` | Nom de l'outil, entrée, sortie | +| `PostToolUseFailure` | Contexte d'erreur | +| `PreCompact` | Réinjecte la mémoire avant compaction | +| `SubagentStart/Stop` | Cycle de vie des sous-agents | +| `Stop` | Résumé de fin de session | +| `SessionEnd` | Marqueur de fin de session | + +### Capacités clés + +| Capacité | Description | +|---|---| +| **Capture automatique** | Chaque usage d'outil enregistré via hooks — zéro effort manuel | +| **Recherche sémantique** | BM25 + vecteur + graphe de connaissances avec fusion RRF | +| **Évolution de la mémoire** | Versioning, supersession, graphes de relations | +| **Oubli automatique** | Expiration TTL, détection de contradictions, éviction par importance | +| **Vie privée d'abord** | Clés d'API, secrets, balises `` retirés avant stockage | +| **Auto-réparation** | Circuit breaker, chaîne de repli de fournisseur, surveillance de santé | +| **Pont Claude** | Synchronisation bidirectionnelle avec MEMORY.md | +| **Graphe de connaissances** | Extraction d'entités + parcours BFS | +| **Mémoire d'équipe** | Partagée + privée par namespace entre membres de l'équipe | +| **Provenance des citations** | Tracer toute mémoire jusqu'aux observations sources | +| **Snapshots git** | Version, rollback et diff de l'état mémoire | + +--- + + + +Récupération triple-flux combinant trois signaux : + +| Flux | Ce qu'il fait | Quand | +|---|---|---| +| **BM25** | Correspondance par mots-clés racinisés avec expansion par synonymes | Toujours actif | +| **Vector** | Similarité cosinus sur embeddings denses | Fournisseur d'embedding configuré | +| **Graph** | Parcours du graphe de connaissances par correspondance d'entités | Entités détectées dans la requête | + +Fusionnés par Reciprocal Rank Fusion (RRF, k=60) et diversifiés par session (max 3 résultats par session). + +BM25 tokenise nativement le grec, le cyrillique, l'hébreu, l'arabe et le latin accentué. Pour des mémoires en chinois / japonais / coréen, installez les segmenteurs optionnels (`npm install @node-rs/jieba tiny-segmenter`) afin de découper les suites CJK en tokens au niveau du mot ; sans eux, agentmemory retombe doucement sur une tokenisation par suite entière et imprime un message indicatif unique sur stderr. + +### Fournisseurs d'embedding + +agentmemory détecte automatiquement votre fournisseur. Pour de meilleurs résultats, installez les embeddings locaux (gratuits) : + +```bash +npm install @xenova/transformers +``` + +| Fournisseur | Modèle | Coût | Notes | +|---|---|---|---| +| **Local (recommandé)** | `all-MiniLM-L6-v2` | Gratuit | Hors-ligne, +8 pp de rappel par rapport à BM25 seul | +| Gemini | `gemini-embedding-001` | Niveau gratuit | 100+ langues, dimensions 768/1536/3072 (MRL), entrée 2048 tokens. Remplace `text-embedding-004` ([déprécié, arrêt le 14 jan. 2026](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | 0,02 $/1M | Meilleure qualité | +| Voyage AI | `voyage-code-3` | Payant | Optimisé pour le code | +| Cohere | `embed-english-v3.0` | Essai gratuit | Polyvalent | +| OpenRouter | N'importe quel modèle | Variable | Proxy multi-modèles | + +--- + +

Serveur MCP

+ +53 outils, 6 ressources, 3 prompts et 4 skills — la boîte à outils mémoire MCP la plus complète pour tout agent. + +> **Shim MCP vs serveur complet :** le paquet publié `@agentmemory/mcp` est un shim léger. Il expose la surface complète de 51 outils **uniquement quand il peut joindre un serveur agentmemory actif** via `AGENTMEMORY_URL` (mode proxy). Sans serveur joignable, le shim retombe sur un jeu local de 7 outils (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). La variable d'env `AGENTMEMORY_TOOLS=core|all` est un drapeau *côté serveur* — la définir dans le bloc `env` du shim n'a aucun effet. Si vous ne voyez que 7 outils dans Cursor / OpenCode / Gemini CLI, lancez `npx @agentmemory/agentmemory` (ou la stack Docker) et définissez `AGENTMEMORY_URL=http://localhost:3111`. + +### 51 outils + +
+Outils de base (toujours disponibles) + +| Outil | Description | +|------|-------------| +| `memory_recall` | Rechercher dans les observations passées | +| `memory_compress_file` | Compresser des fichiers markdown en préservant la structure | +| `memory_save` | Sauvegarder un insight, une décision ou un motif | +| `memory_patterns` | Détecter des motifs récurrents | +| `memory_smart_search` | Recherche hybride sémantique + mots-clés | +| `memory_file_history` | Observations passées sur des fichiers spécifiques | +| `memory_sessions` | Lister les sessions récentes | +| `memory_timeline` | Observations chronologiques | +| `memory_profile` | Profil de projet (concepts, fichiers, motifs) | +| `memory_export` | Exporter toutes les données mémoire | +| `memory_relations` | Interroger le graphe de relations | + +
+ +
+Outils étendus (51 au total — définissez AGENTMEMORY_TOOLS=all) + +| Outil | Description | +|------|-------------| +| `memory_patterns` | Détecter des motifs récurrents | +| `memory_timeline` | Observations chronologiques | +| `memory_relations` | Interroger le graphe de relations | +| `memory_graph_query` | Parcours du graphe de connaissances | +| `memory_consolidate` | Lancer la consolidation à 4 niveaux | +| `memory_claude_bridge_sync` | Synchroniser avec MEMORY.md | +| `memory_team_share` | Partager avec les membres de l'équipe | +| `memory_team_feed` | Éléments partagés récemment | +| `memory_audit` | Piste d'audit des opérations | +| `memory_governance_delete` | Supprimer avec piste d'audit | +| `memory_snapshot_create` | Snapshot versionné git | +| `memory_action_create` | Créer des éléments de travail avec dépendances | +| `memory_action_update` | Mettre à jour le statut d'une action | +| `memory_frontier` | Actions débloquées classées par priorité | +| `memory_next` | Seule action suivante la plus importante | +| `memory_lease` | Leases d'action exclusifs (multi-agents) | +| `memory_routine_run` | Instancier des routines de workflow | +| `memory_signal_send` | Messagerie inter-agents | +| `memory_signal_read` | Lire des messages avec accusés | +| `memory_checkpoint` | Portes de condition externes | +| `memory_mesh_sync` | Sync P2P entre instances | +| `memory_sentinel_create` | Watchers événementiels | +| `memory_sentinel_trigger` | Déclencher des sentinelles depuis l'extérieur | +| `memory_sketch_create` | Graphes d'action éphémères | +| `memory_sketch_promote` | Promouvoir en permanent | +| `memory_crystallize` | Compacter les chaînes d'actions | +| `memory_diagnose` | Vérifications de santé | +| `memory_heal` | Auto-correction d'état bloqué | +| `memory_facet_tag` | Tags dimension:valeur | +| `memory_facet_query` | Interroger par tags de facettes | +| `memory_verify` | Tracer la provenance | + +
+ +### 6 Ressources · 3 Prompts · 4 Skills + +| Type | Nom | Description | +|------|------|-------------| +| Ressource | `agentmemory://status` | Santé, nombre de sessions, nombre de mémoires | +| Ressource | `agentmemory://project/{name}/profile` | Intelligence par projet | +| Ressource | `agentmemory://memories/latest` | 10 dernières mémoires actives | +| Ressource | `agentmemory://graph/stats` | Statistiques du graphe de connaissances | +| Prompt | `recall_context` | Recherche + retour de messages de contexte | +| Prompt | `session_handoff` | Données de passation entre agents | +| Prompt | `detect_patterns` | Analyser les motifs récurrents | +| Skill | `/recall` | Rechercher la mémoire | +| Skill | `/remember` | Sauvegarder en mémoire long-terme | +| Skill | `/session-history` | Résumés de sessions récentes | +| Skill | `/forget` | Supprimer observations / sessions | + +### MCP autonome + +Tourne sans le serveur complet — pour n'importe quel client MCP. L'une ou l'autre marche : + +```bash +npx -y @agentmemory/agentmemory mcp # canonical (always available) +npx -y @agentmemory/mcp # shim package alias +``` + +Ou ajoutez à la config MCP de votre agent : + +La plupart des agents (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI) : +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +Fusionnez l'entrée `agentmemory` dans l'objet `mcpServers` existant de votre hôte plutôt que de remplacer le fichier. Pour des clients en sandbox qui ne peuvent pas joindre le `localhost` de l'hôte, ajoutez `"AGENTMEMORY_FORCE_PROXY": "1"` au bloc env et pointez `AGENTMEMORY_URL` vers une route que la sandbox peut atteindre. + +OpenCode (`opencode.json`) : +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Copiez le fichier plugin depuis le dépôt : +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Visualiseur temps réel

+ +Démarre automatiquement sur le port `3113`. Flux d'observations en direct, explorateur de sessions, navigateur mémoire, visualisation du graphe de connaissances et tableau de bord de santé. + +```bash +open http://localhost:3113 +``` + +Le serveur du visualiseur se lie à `127.0.0.1` par défaut. Le point d'entrée `/agentmemory/viewer` servi par REST suit les règles normales de bearer-token `AGENTMEMORY_SECRET`. Les en-têtes CSP utilisent un nonce de script par réponse et désactivent les attributs gestionnaires inline (`script-src-attr 'none'`). + +--- + +

iii Console

+ +Le visualiseur sur `:3113` montre ce que votre agent **a mémorisé**. La [iii console](https://iii.dev/docs/console) montre ce que votre agent **a fait** — chaque op mémoire comme trace OpenTelemetry, chaque entrée KV éditable, chaque fonction invocable, chaque flux taps-able. Deux fenêtres sur la même mémoire : l'une orientée produit, l'autre orientée moteur. + +Regardez un `memory_smart_search` se déclencher et voyez le scan BM25 → recherche d'embeddings → fusion RRF → reranker comme un waterfall. Éditez un timer de consolidation bloqué dans le navigateur KV. Rejouez un hook `PostToolUse` avec une charge utile modifiée. Épinglez le flux WebSocket et regardez les observations arriver en direct. + +agentmemory livre cela gratuitement parce que chaque fonction, trigger, scope d'état et flux est une primitive iii — rien de personnalisé, rien à instrumenter. + +

+ iii console — page Workers montrant les workers connectés, dont les instances agentmemory avec compteurs de fonctions en direct et métadonnées de runtime +
+ Page Workers : chaque worker connecté — y compris agentmemory lui-même — avec PID, nombre de fonctions, runtime et last-seen. +

+ +**Déjà installé.** La console est livrée avec `iii` — pas d'installeur séparé. + +**Lancer aux côtés d'agentmemory :** + +```bash +# agentmemory viewer holds port 3113, so run the console on 3114. +# Engine REST (3111), WebSocket (3112), and bridge (49134) defaults match agentmemory. +iii console --port 3114 +``` + +Puis ouvrez `http://localhost:3114`. Ajoutez `--enable-flow` pour la page expérimentale de graphe d'architecture. + +Surchargez les endpoints du moteur uniquement si vous les avez déplacés : + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**Ce que vous pouvez faire depuis la console :** + +| Page | Pour | +|------|-----------| +| **Workers** | Voir chaque worker connecté et ses métriques en direct — y compris le worker agentmemory lui-même. | +| **Functions** | Invoquer n'importe quelle fonction d'agentmemory avec une charge utile JSON — pratique pour tester `memory.recall`, `memory.consolidate`, `graph.query` sans câbler un client. | +| **Triggers** | Rejouer les triggers HTTP, cron, event et state — déclencher manuellement le cron de consolidation, retenter une route HTTP, émettre un changement d'état. | +| **States** | Navigateur KV avec CRUD complet — sessions, slots mémoire, timers de cycle de vie, index d'embeddings — éditer les valeurs sur place. | +| **Streams** | Moniteur WebSocket en direct pour les écritures mémoire, événements de hooks et mises à jour d'observations à mesure qu'ils circulent dans les iii streams. | +| **Queues** | Topics de files durables + gestion de la dead-letter. Rejouer ou abandonner les jobs d'embedding / compression échoués. | +| **Traces** | Vues waterfall / flame / décomposition par service OpenTelemetry. Filtrez par `trace_id` pour voir exactement quelles fonctions, appels DB et requêtes d'embedding une seule `memory.search` a produits. | +| **Logs** | Logs OTEL structurés filtrés et corrélés aux IDs de trace/span. | +| **Config** | Configuration runtime — voir exactement quels workers, fournisseurs et ports tourne votre moteur. | +| **Flow** | (Optionnel, `--enable-flow`) Graphe d'architecture interactif de chaque worker, trigger et flux. | + +

+ vue waterfall de traces dans iii console montrant la durée par span +
+ Traces : waterfall / flame / décomposition par service pour chaque opération mémoire. +

+ +**Les traces sont déjà actives :** + +`iii-config.yaml` est livré avec le worker `iii-observability` activé (`exporter: memory`, `sampling_ratio: 1.0`, métriques + logs). Aucune config supplémentaire — dès qu'agentmemory démarre, chaque opération mémoire émet un span de trace et un log structuré que la console peut lire. + +Si vous voulez exporter vers Jaeger/Honeycomb/Grafana Tempo à la place, changez `exporter: memory` en `exporter: otlp` et définissez l'endpoint du collecteur selon la documentation d'observabilité d'iii. + +> **Attention :** aucune auth n'est appliquée sur la console elle-même — gardez-la liée à `127.0.0.1` (par défaut) et ne l'exposez jamais publiquement. + +--- + +

Powered by iii

+ +agentmemory est **déjà une instance [iii](https://iii.dev) en cours d'exécution**. Fonctions, triggers, état KV, flux, traces OTEL — tout est primitive iii. Vous n'avez pas installé Postgres, Redis, Express, pm2, ni Prometheus, parce qu'iii les remplace. + +Cela signifie qu'une commande supplémentaire étend agentmemory d'une toute nouvelle capacité. + +### Étendez agentmemory avec une seule commande + +```bash +iii worker add iii-pubsub # fan memory writes out to every connected instance +iii worker add iii-cron # scheduled consolidation, decay sweeps, snapshot rotation +iii worker add iii-queue # durable retries for embedding + compression jobs +iii worker add iii-observability # OTEL traces on every memory op (default on) +iii worker add iii-sandbox # run recalled code inside an isolated microVM +iii worker add iii-database # swap in a SQL-backed state adapter +iii worker add mcp # generic MCP host alongside the agentmemory MCP +``` + +Chaque `iii worker add` enregistre de nouvelles fonctions et triggers dans le même moteur sur lequel agentmemory tourne déjà. Le visualiseur et la console les prennent en compte immédiatement — sans rechargement, sans nouvelle intégration, sans nouveau conteneur. + +| `iii worker add` | Ce que vous obtenez en plus d'agentmemory | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | Mémoire multi-instances : chaque `remember` se diffuse, chaque `search` lit l'union | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Cycle de vie planifié — consolidation nocturne, snapshots hebdomadaires, décroissance sur horloge fixe | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | Retries durables : les jobs d'embedding + compression en échec survivent au redémarrage, aucune observation perdue | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | Traces, métriques et logs OTEL sur chaque fonction — câblés dans `iii-config.yaml` dès le premier jour | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | Le code issu de `memory_recall` s'exécute dans une VM jetable, pas dans votre shell | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | Adaptateur d'état adossé à SQL lorsque vous dépassez les valeurs par défaut KV en mémoire | +| [`mcp`](https://workers.iii.dev/workers/mcp) | Déployez des serveurs MCP supplémentaires à côté de celui d'agentmemory, partageant le même moteur | + +Registre complet : [workers.iii.dev](https://workers.iii.dev). Chaque worker là-bas se compose via les mêmes primitives qu'utilise agentmemory — et l'agentmemory que vous avez déjà en est un. + +### Ce qu'iii remplace + +| Stack traditionnelle | agentmemory utilise | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + index vectoriel en mémoire | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | Supervision de workers du moteur iii | +| Prometheus / Grafana | iii OTEL + moniteur de santé | +| Systèmes de plugins personnalisés | `iii worker add ` | + +**118 fichiers sources · ~21 800 LOC · 950+ tests · 123 fonctions · 34 scopes KV** — tout sur trois primitives. Pas de `agentmemory plugin install`. Le système de plugins, c'est iii lui-même. + +--- + +

Configuration

+ +### Fournisseurs LLM + +agentmemory détecte automatiquement depuis votre environnement. Par défaut, aucun appel LLM n'est effectué tant que vous n'avez pas configuré de fournisseur ou explicitement opté pour le fallback abonnement Claude. + +| Fournisseur | Config | Notes | +|----------|--------|-------| +| **No-op (par défaut)** | Aucune config nécessaire | Le compress/summarize adossé à un LLM est DÉSACTIVÉ. La compression et le recall BM25 synthétiques fonctionnent toujours. Voir `AGENTMEMORY_ALLOW_AGENT_SDK` ci-dessous si vous comptiez sur le fallback abonnement Claude. | +| Anthropic API | `ANTHROPIC_API_KEY` | Facturation au token | +| MiniMax | `MINIMAX_API_KEY` | Compatible Anthropic | +| Gemini | `GEMINI_API_KEY` | Active aussi les embeddings | +| OpenRouter | `OPENROUTER_API_KEY` | N'importe quel modèle | +| Fallback abonnement Claude | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Opt-in seulement. Engendre des sessions `@anthropic-ai/claude-agent-sdk` — provoquait une récursion non bornée du Stop-hook, il n'est plus l'option par défaut. | + +### Sélection de modèle attentive au coût + +La compression en arrière-plan tourne sur chaque observation, donc le choix du modèle influence sensiblement la dépense mensuelle. Données de charge capturées : 635 requêtes / 888K tokens / 35 heures d'usage actif, exécutées contre trois modèles OpenRouter au tarif du 2026-05-23. + +| Niveau | Modèle | Entrée / 1M | Sortie / 1M | Coût pour les 35h capturées | Notes | +|------|-------|------------|-------------|---------------------------|-------| +| Recommandé | `deepseek/deepseek-v4-pro` | 0,435 $ | 0,87 $ | ~0,46 $ | Qualité de compression + résumé solide à un coût ~10× moindre que Sonnet. | +| Recommandé | `deepseek/deepseek-chat` | 0,27 $ | 1,10 $ | ~0,40 $ | Plus ancien mais toujours satisfaisant pour des charges de compression uniquement. | +| Recommandé | `qwen/qwen3-coder` | 0,45 $ | 1,80 $ | ~0,55 $ | Solide raisonnement code si vos sessions sont fortement code-shaped. | +| Premium | `anthropic/claude-sonnet-4.6` | 3,00 $ | 15,00 $ | ~5,02 $ | Haute qualité mais coûteux pour du travail de fond permanent. | +| Premium | `openai/gpt-4o` | 2,50 $ | 10,00 $ | ~4,20 $ | Niveau similaire à Sonnet. | +| À éviter | `anthropic/claude-opus-4.6` | 15,00 $ | 75,00 $ | ~25+ $ | Modèle classe raisonnement ; surcoût massif pour de la compression. | + +agentmemory imprime un avertissement runtime quand `OPENROUTER_MODEL` correspond à un motif de niveau premium. Définissez `AGENTMEMORY_SUPPRESS_COST_WARNING=1` pour le faire taire une fois votre choix éclairé. + +Compromis qualité vs coût pour le travail mémoire : la compression est une tâche de résumé avec des exigences de qualité relativement souples (c'est l'agent qui relit le résumé, pas l'utilisateur). DeepSeek-V4-Pro / Qwen3-Coder se situent à la précision d'arrondi près de Sonnet sur cette tâche tout en coûtant ~10× moins. Réservez les modèles premium aux requêtes que vous lisez directement. + +Sources : [tarification OpenRouter pour Sonnet 4.6](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing), [DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro), [notes de prix DeepSeek](https://api-docs.deepseek.com/quick_start/pricing/). + +### Mémoire multi-agents (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +Dans des configurations multi-agents où plusieurs rôles partagent un même serveur agentmemory (architect / developer / reviewer / researcher / support-agent), `AGENT_ID` marque chaque écriture avec le rôle qui l'a produite. `AGENTMEMORY_AGENT_SCOPE` contrôle si le recall filtre par ce tag. + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" +``` + +Deux modes : + +| Mode | Marquer les écritures | Filtrer le recall | Quand l'utiliser | +|------|------------|---------------|-------------| +| `shared` (par défaut) | oui | non | Contexte inter-agents avec piste d'audit. L'architect voit ce que le developer a noté, mais chaque ligne enregistre qui l'a dit. | +| `isolated` | oui | oui | Séparation stricte. L'architect ne voit jamais les observations / mémoires / sessions du developer. | + +Ce qui est marqué quand `AGENT_ID` est défini : `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. Le rôle circule de `api::session::start` → `mem::observe` → `mem::compress` → KV. + +Ce qui est filtré en mode isolé : `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Chaque endpoint accepte `?agentId=` pour surcharger par requête, et `?agentId=*` pour se désinscrire entièrement du scope de l'env. `/memories` accepte aussi `?includeOrphans=true` pour faire remonter les mémoires antérieures à AGENT_ID dont `agentId` est indéfini. + +Surcharge par appel au niveau SDK / REST : chaque endpoint mutant (`/session/start`, `/remember`) accepte un champ `agentId` dans le corps de la requête qui gagne sur l'env. Utile pour des runtimes qui routent plusieurs rôles à travers un même processus serveur. + +Quand `AGENT_ID` n'est pas défini, la mémoire reste non scopée (comportement legacy, sans tags ni filtres). + +### Ports + +agentmemory + iii-engine se lient à quatre ports par défaut. Si un redémarrage échoue avec `port in use`, ce tableau vous indique le processus à chercher. + +| Port | Processus | Usage | Surcharge env | +|------|---------|---------|--------------| +| `3111` | agentmemory | API REST + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | Worker streams interne (consommé par agentmemory + visualiseur) | `III_STREAMS_PORT` | +| `3113` | agentmemory | Visualiseur temps réel (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — les workers s'y enregistrent, la télémétrie OTel y circule | `III_ENGINE_URL` (URL complète, défaut `ws://localhost:49134`) | + +Nettoyage de processus zombies quand des ports restent occupés après un crash : + +```bash +# macOS / Linux — find whatever is on each port and kill it +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` réclame proprement à la fois le worker et le pidfile du moteur en arrêt gracieux. Le nettoyage manuel ci-dessus n'est nécessaire que pour le cas post-crash où aucun pidfile n'est laissé en place. + +### Fichier de configuration + +Mettez la configuration runtime d'agentmemory dans `~/.agentmemory/.env` au lieu d'exporter des variables dans chaque shell. Si le visualiseur affiche un indice de setup comme `export ANTHROPIC_API_KEY=...`, recopiez-le dans ce fichier sous la forme `ANTHROPIC_API_KEY=...` sans le préfixe `export`, puis redémarrez agentmemory. + +Les variables d'environnement du processus restent valides et prennent le pas sur les valeurs du fichier. + +Sur Windows, le même fichier se trouve dans `%USERPROFILE%\.agentmemory\.env` : + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +Pour tester avec un abonnement Claude Code Pro/Max au lieu d'une clé d'API, optez-vous explicitement : + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +Activez les fonctionnalités de graphe ou de consolidation dans le même fichier si vous les voulez : + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### Variables d'environnement + +Créez `~/.agentmemory/.env` : + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +124 endpoints sur le port `3111`. L'API REST se lie à `127.0.0.1` par défaut. Les endpoints protégés exigent `Authorization: Bearer ` lorsque `AGENTMEMORY_SECRET` est défini, et les endpoints de mesh sync exigent `AGENTMEMORY_SECRET` sur les deux pairs. + +
+Endpoints clés + +| Méthode | Chemin | Description | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | Vérification de santé (toujours publique) | +| `POST` | `/agentmemory/session/start` | Démarrer une session + obtenir le contexte | +| `POST` | `/agentmemory/session/end` | Terminer une session | +| `POST` | `/agentmemory/observe` | Capturer une observation | +| `POST` | `/agentmemory/smart-search` | Recherche hybride | +| `POST` | `/agentmemory/context` | Générer du contexte | +| `POST` | `/agentmemory/remember` | Sauvegarder en mémoire long-terme | +| `POST` | `/agentmemory/forget` | Supprimer des observations | +| `POST` | `/agentmemory/enrich` | Contexte de fichier + mémoires + bugs | +| `GET` | `/agentmemory/profile` | Profil de projet | +| `GET` | `/agentmemory/export` | Exporter toutes les données | +| `POST` | `/agentmemory/import` | Importer depuis JSON | +| `POST` | `/agentmemory/graph/query` | Requête sur le graphe de connaissances | +| `POST` | `/agentmemory/team/share` | Partager avec l'équipe | +| `GET` | `/agentmemory/audit` | Piste d'audit | + +Liste complète des endpoints : [`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Développement

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 950+ tests +npm run test:integration # API tests (requires running services) +``` + +**Prérequis :** Node.js >= 20, [iii-engine](https://iii.dev/docs) ou Docker + +

Licence

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.hi-IN.md b/READMEs/README.hi-IN.md new file mode 100644 index 0000000..00358fa --- /dev/null +++ b/READMEs/README.hi-IN.md @@ -0,0 +1,1379 @@ +

+ agentmemory — AI कोडिंग एजेंट्स के लिए स्थायी मेमोरी +

+ +

+ + आपका कोडिंग एजेंट सब कुछ याद रखता है। बार-बार समझाने की ज़रूरत नहीं। + Built on iii engine +
+ Claude Code, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode, और किसी भी MCP क्लाइंट के लिए स्थायी मेमोरी। +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Design doc: 1200 stars / 172 forks on the gist +

+ +

+ यह gist Karpathy के LLM Wiki पैटर्न को confidence scoring, lifecycle, knowledge graphs और hybrid search के साथ बढ़ाता है: agentmemory इसका implementation है। +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ agentmemory demo +

+ +

+ इंस्टॉल • + क्विक स्टार्ट • + बेंचमार्क्स • + प्रतिस्पर्धियों से तुलना • + एजेंट्स • + यह कैसे काम करता है • + MCP • + व्यूअर • + iii कंसोल • + iii द्वारा संचालित • + कॉन्फ़िग • + API +

+ +--- + +## इंस्टॉल + +```bash +npm install -g @agentmemory/agentmemory # एक बार — PATH पर `agentmemory` कमांड उपलब्ध +# अगर macOS/Linux सिस्टम Node इंस्टॉल पर EACCES त्रुटि आती है, तो इसके साथ फिर से चलाएँ: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # :3111 पर मेमोरी सर्वर शुरू करें +agentmemory demo # नमूना सेशंस सीड करें + recall साबित करें +agentmemory connect claude-code # अपना एजेंट जोड़ें (अन्य: codex, cursor, gemini-cli, ...) +``` + +या `npx` के माध्यम से (इंस्टॉल की ज़रूरत नहीं): + +```bash +npx @agentmemory/agentmemory +``` + +ध्यान दें — npx प्रति-वर्ज़न कैश करता है। अगर बेयर `npx @agentmemory/agentmemory` कोई पुराना रिलीज़ चला रहा है, तो नवीनतम को `npx -y @agentmemory/agentmemory@latest` से ज़बरदस्ती चलाएँ, या एक बार `rm -rf ~/.npm/_npx` से कैश साफ़ करें (macOS/Linux; Windows पर `%LOCALAPPDATA%\npm-cache\_npx` हटाएँ)। v0.9.16+ के बाद पहली npx रन आपको इनलाइन ग्लोबल इंस्टॉल करने का प्रॉम्प्ट देती है ताकि बेयर `agentmemory` कमांड हर जगह काम करे। + +पूर्ण विकल्प नीचे [क्विक स्टार्ट](#quick-start) में हैं। एजेंट-विशिष्ट कॉन्फ़िगरेशन [हर एजेंट के साथ काम करता है](#works-with-every-agent) में। + +--- + +

Works with every agent

+ +agentmemory किसी भी ऐसे एजेंट के साथ काम करता है जो hooks, MCP, या REST API सपोर्ट करता है। सभी एजेंट एक ही मेमोरी सर्वर साझा करते हैं। + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+native plugin + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+native plugin + 6 hooks + MCP +
+OpenClaw
+OpenClaw
+native plugin + MCP +
+Hermes
+Hermes
+native plugin + MCP +
+pi
+pi
+native plugin + MCP +
+OpenHuman
+OpenHuman
+native Memory trait बैकएंड +
+Cursor
+Cursor
+MCP सर्वर +
+Gemini CLI
+Gemini CLI
+MCP सर्वर +
+OpenCode
+OpenCode
+22 hooks + MCP + plugin +
+Cline
+Cline
+MCP सर्वर +
+Goose
+Goose
+MCP सर्वर +
+Kilo Code
+Kilo Code
+MCP सर्वर +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP सर्वर +
+Windsurf
+Windsurf
+MCP सर्वर +
+Roo Code
+Roo Code
+MCP सर्वर +
+ +

+ MCP या HTTP बोलने वाले किसी भी एजेंट के साथ काम करता है। एक सर्वर, सभी के बीच साझा मेमोरी। +

+ +--- + +आप हर सेशन में वही आर्किटेक्चर समझाते हैं। आप वही bugs बार-बार खोजते हैं। आप वही प्राथमिकताएँ फिर से सिखाते हैं। बिल्ट-इन मेमोरी (CLAUDE.md, .cursorrules) 200 लाइनों पर सीमित है और पुरानी हो जाती है। agentmemory इसे ठीक करता है। यह चुपचाप आपके एजेंट की गतिविधियाँ कैप्चर करता है, उन्हें खोज योग्य मेमोरी में संकुचित करता है, और अगला सेशन शुरू होने पर सही संदर्भ इंजेक्ट करता है। एक कमांड। सभी एजेंट्स के साथ काम करता है। + +**क्या बदलता है:** सेशन 1 में आप JWT auth सेटअप करते हैं। सेशन 2 में आप rate limiting माँगते हैं। एजेंट को पहले से पता है कि आपकी auth `src/middleware/auth.ts` में jose middleware का उपयोग करती है, आपके tests token validation को कवर करते हैं, और आपने Edge compatibility के लिए jsonwebtoken के बजाय jose चुना है। फिर से समझाना नहीं। कॉपी-पेस्ट नहीं। एजेंट बस *जानता है*। + +```bash +npx @agentmemory/agentmemory +``` + +> **v0.9.0 में नया** — लैंडिंग साइट [agent-memory.dev](https://agent-memory.dev), फाइलसिस्टम कनेक्टर (`@agentmemory/fs-watcher`), स्टैंडअलोन MCP अब चल रहे सर्वर को प्रॉक्सी करता है ताकि hooks और व्यूअर सहमत हों, हर delete path में audit policy कोडिफाई की गई, small Node प्रक्रियाओं पर health अब `memory_critical` फ़्लैग नहीं करता। पूरे नोट्स [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18) में। + +--- + +

Benchmarks

+ + + + + + +
+ +### Retrieval सटीकता + +**coding-agent-life-v1** (in-house corpus, sandbox-reproducible) + +| Adapter | P@5 | R@5 | Top-5 hit rate | p50 latency | +|---|---|---|---|---| +| **agentmemory hybrid** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep baseline | 0.267 | 0.967 | 15 / 15 | 0 ms | + +100% top-5 hit rate। समान input पर grep baseline से **2.2×** बेहतर precision। पूरी प्रकार-वार breakdown: [`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 प्रश्न) + +| System | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| BM25-only fallback | 86.2% | 94.6% | 71.5% | + + + +### Token बचत + +| दृष्टिकोण | Tokens/yr | Cost/yr | +|---|---|---| +| पूरा context paste करें | 19.5M+ | असंभव (window से अधिक) | +| LLM-summarized | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + local embeddings | ~170K | **$0** | + +
+ +> Embedding मॉडल: `all-MiniLM-L6-v2` (local, free, कोई API key नहीं)। पूरी रिपोर्ट्स: [`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](../benchmark/QUALITY.md), [`benchmark/SCALE.md`](../benchmark/SCALE.md)। प्रतिस्पर्धी तुलना: [`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory बनाम mem0, Letta, Khoj, claude-mem, Hippo। + +**स्थानीय रूप से reproduce करें:** [`eval/README.md`](../eval/README.md) — LongMemEval `_s` (public 500-Q) + `coding-agent-life-v1` (in-house 15-session corpus) के लिए adapter-pluggable harness। Grep / vector / agentmemory adapters साथ-साथ scored होते हैं, NDJSON output, प्रकाशित scorecards [`docs/benchmarks/`](../docs/benchmarks/) में जाते हैं। + +**[codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything), और [Graphify](https://github.com/safishamsi/graphify) के साथ जोड़ता है।** Code-graph indexing, multi-agent build pipelines, और docs / PDFs / images / videos में व्यापक knowledge graphs। agentmemory काम याद रखता है; ये तीन प्रोजेक्ट्स context layer के बाकी हिस्से को रोशन करते हैं। Recipes + question-routing table: [`docs/recipes/pairings.md`](../docs/recipes/pairings.md)। + +--- + +

vs Competitors

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)बिल्ट-इन (CLAUDE.md)
प्रकारMemory engine + MCP सर्वरMemory layer APIपूर्ण agent runtimeStatic फाइल
Retrieval R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/A (grep)
स्वचालित कैप्चर12 hooks (शून्य मैनुअल प्रयास)मैनुअल add() कॉलएजेंट self-editsमैनुअल editing
खोजBM25 + Vector + Graph (RRF fusion)Vector + GraphVector (archival)सब कुछ context में लोड करता है
Multi-agentMCP + REST + leases + signalsAPI (कोई coordination नहीं)केवल Letta runtime मेंप्रति-एजेंट फाइलें
Framework lock-inकोई नहीं (कोई भी MCP क्लाइंट)कोई नहींउच्च (Letta का उपयोग आवश्यक)प्रति-एजेंट format
बाहरी निर्भरताएँकोई नहीं (SQLite + iii-engine)Qdrant / pgvectorPostgres + vector DBकोई नहीं
Memory lifecycle4-tier consolidation + decay + auto-forgetPassive extractionAgent-managedमैनुअल pruning
Token दक्षता~1,900 tokens/session ($10/yr)integration पर निर्भरCore memory context में240 observations पर 22K+ tokens
Real-time व्यूअरहाँ (port 3113)Cloud dashboardCloud dashboardनहीं
Self-hostedहाँ (default)OptionalOptionalहाँ
+ +--- + +

Quick Start

+ +संगतता: यह रिलीज़ stable `iii-sdk` `^0.11.0` और iii-engine v0.11.x को टार्गेट करता है। + +### 30 सेकंड में आज़माएँ + +```bash +# Terminal 1: सर्वर शुरू करें +npx @agentmemory/agentmemory + +# Terminal 2: नमूना डेटा सीड करें और recall को कार्य में देखें +npx @agentmemory/agentmemory demo +``` + +`demo` 3 यथार्थवादी सेशंस सीड करता है (JWT auth, N+1 query fix, rate limiting) और उन पर semantic searches चलाता है। जब आप "database performance optimization" खोजते हैं तो आप देखेंगे कि यह "N+1 query fix" ढूँढ़ लेता है — keyword matching ऐसा नहीं कर सकती। + +मेमोरी को लाइव बनते हुए देखने के लिए `http://localhost:3113` खोलें। + +### अनुशंसित: globally इंस्टॉल करें + +`npx` per-version कैश करता है। अगर आपने पिछले हफ्ते `npx @agentmemory/agentmemory@0.9.14` चलाया था, तो एक बेयर `npx @agentmemory/agentmemory` `~/.npm/_npx/` से stale 0.9.14 दे सकता है, न कि नवीनतम रिलीज़। एक बार इंस्टॉल करें और बेयर `agentmemory` कमांड हर जगह काम करता है: + +```bash +npm install -g @agentmemory/agentmemory +# अगर macOS/Linux सिस्टम Node इंस्टॉल पर EACCES त्रुटि आती है, इसके साथ फिर से चलाएँ: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # सर्वर शुरू करें (npx form के समान) +agentmemory stop # बंद करें +agentmemory remove # हमने जो भी बनाया उसे अनइंस्टॉल करें +agentmemory connect claude-code # एक एजेंट जोड़ें +agentmemory doctor # interactive diagnostics + fix prompts +``` + +v0.9.16 के बाद से, पहली npx रन आपको inline globally इंस्टॉल करने का प्रॉम्प्ट देती है — एक बार `Y` जवाब दें और तैयार। अगर आप skip करते हैं, तो ताज़ा fetch के लिए इनमें से किसी पर भी fallback करें: + +```bash +npx -y @agentmemory/agentmemory@latest # npm से नवीनतम को force करता है (cross-platform) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # केवल macOS/Linux (POSIX shell) +``` + +Windows / PowerShell पर, समतुल्य cache clear है `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` — ऊपर का `npx -y ...@latest` form cross-platform विकल्प है। + +### Session Replay + +agentmemory द्वारा रिकॉर्ड किया गया हर सेशन replayable है। व्यूअर खोलें, **Replay** टैब चुनें, और timeline scrub करें: prompts, tool calls, tool results, और responses अलग events के रूप में render होते हैं, play/pause, speed control (0.5×–4×), और keyboard shortcuts (space toggle के लिए, arrows step के लिए) के साथ। + +क्या आपके पास पहले से पुरानी Claude Code JSONL transcripts हैं जिन्हें आप लाना चाहते हैं? + +```bash +# डिफ़ॉल्ट ~/.claude/projects के तहत सब कुछ import करें +npx @agentmemory/agentmemory import-jsonl + +# या एक अकेली फाइल import करें +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +Imported सेशंस native ones के साथ Replay picker में दिखते हैं। हुड के नीचे प्रत्येक entry `mem::replay::load`, `mem::replay::sessions`, और `mem::replay::import-jsonl` iii functions के माध्यम से रूट होती है — कोई side-channel servers नहीं। + +### Upgrade / Maintenance + +जब आप जानबूझकर अपने local runtime को update करना चाहते हैं तो maintenance command का उपयोग करें: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +चेतावनी: यह कमांड वर्तमान workspace/runtime को mutate करता है। यह JavaScript निर्भरताएँ update कर सकता है और pinned Docker image `iiidev/iii:0.11.2` खींच सकता है। यह कभी भी unpinned या नया iii engine install नहीं करता। + +Implementation विवरण `src/cli.ts` में हैं (`src/cli.ts:544-595` क्षेत्र के आसपास `runUpgrade` देखें)। + +### Claude Code (एक block, paste करें) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Plugin install के बिना Claude Code (MCP-standalone path) + +अगर आप `/plugin install` का उपयोग करने के बजाय `~/.claude.json` के माध्यम से सीधे agentmemory का MCP सर्वर कनेक्ट करते हैं, तो Claude Code कभी भी `${CLAUDE_PLUGIN_ROOT}` resolve नहीं करता और आपको hook scripts को `~/.claude/settings.json` में absolute paths पर point करना पड़ता है। ये paths आमतौर पर agentmemory version को embed करते हैं (जैसे `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), इसलिए अगला upgrade चुपचाप हर hook को तोड़ देता है। + +Workaround: + +```bash +agentmemory connect claude-code --with-hooks +``` + +यह वही hook commands को `~/.claude/settings.json` में merge करता है, current installed `@agentmemory/agentmemory` package की bundled `plugin/` directory पर resolve किए गए absolute paths के साथ। agentmemory upgrade करने के बाद paths refresh करने के लिए कमांड फिर से चलाएँ। उसी फाइल में user entries संरक्षित होती हैं; केवल पिछली agentmemory entries replace होती हैं। `/plugin install` path अनुशंसित approach बनी रहती है। +Remote या protected deployments के लिए, Claude Code को `AGENTMEMORY_URL` और `AGENTMEMORY_SECRET` set के साथ launch करें। Plugin दोनों values को इसके bundled MCP सर्वर के माध्यम से pass करता है; जब `AGENTMEMORY_URL` खाली होता है, तो MCP shim `http://localhost:3111` का उपयोग करता है। + +### Codex CLI (Codex plugin platform) + +```bash +# 1. एक अलग terminal में memory सर्वर शुरू करें +npx @agentmemory/agentmemory + +# 2. agentmemory marketplace register करें और plugin install करें +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Codex plugin उसी `plugin/` directory से ship होता है जिससे Claude Code plugin। यह register करता है: + +- `@agentmemory/mcp` MCP सर्वर के रूप में (जब `AGENTMEMORY_URL` चल रहे agentmemory सर्वर पर point करता है, तो सभी 51 tools proxy करता है; कोई पहुँच योग्य सर्वर न होने पर locally 7 tools पर fallback करता है) +- 6 lifecycle hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 4 skills: `/recall`, `/remember`, `/session-history`, `/forget` + +Codex का hook engine hook subprocesses में `CLAUDE_PLUGIN_ROOT` inject करता है ([`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs) के अनुसार), इसलिए वही hook scripts duplication के बिना दोनों hosts में काम करते हैं। Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure events केवल Claude-Code-only हैं और Codex के लिए register नहीं होते। + +#### Codex Desktop: plugin hooks वर्तमान में silent हैं (workaround उपलब्ध) + +`CodexHooks` और `PluginHooks` दोनों [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs) में stable + default-enabled हैं, लेकिन Codex Desktop builds वर्तमान में plugin-local `hooks.json` dispatch नहीं करते ([openai/codex#16430](https://github.com/openai/codex/issues/16430))। MCP tools अभी भी काम करते हैं; केवल lifecycle observations छूट जाते हैं। + +जब तक upstream fix land नहीं करता, वही hook commands को global `~/.codex/hooks.json` में mirror करें: + +```bash +agentmemory connect codex --with-hooks +``` + +यह `~/.codex/hooks.json` में एक idempotent block जोड़ता है जो bundled scripts के absolute paths को reference करता है (user-scope पर `${CLAUDE_PLUGIN_ROOT}` expansion की ज़रूरत नहीं)। agentmemory upgrade के बाद paths refresh करने के लिए वही कमांड फिर से चलाएँ। उसी फाइल में user entries संरक्षित रहती हैं; केवल पिछली agentmemory entries replace होती हैं। + +
+OpenClaw (यह prompt paste करें) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +पूर्ण गाइड: [`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent (यह prompt paste करें) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +पूर्ण गाइड: [`integrations/hermes/`](../integrations/hermes/) + +
+ +### अन्य एजेंट्स + +मेमोरी सर्वर शुरू करें: `npx @agentmemory/agentmemory` + +agentmemory entry `mcpServers` shape का उपयोग करने वाले हर host में **वही MCP server block** है (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw): + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**इस entry को host की config file में मौजूदा `mcpServers` object में merge करें** — file को replace न करें। अगर फाइल में पहले से अन्य servers हैं, तो `mcpServers` के अंदर एक और key के रूप में `agentmemory` को उनके बगल में जोड़ें। अगर `mcpServers` पूरी तरह से missing है, तो block को `{ "mcpServers": { ... } }` के अंदर paste करें। `${VAR}` placeholders MCP-server launch पर shell से `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` inherit करते हैं — unset variables empty strings pass करते हैं और shim `http://localhost:3111` पर fallback होता है। एक wired entry local और remote (k8s / reverse-proxied) दोनों deployments को कवर करती है। + +| एजेंट | Config फाइल | नोट्स | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | `mcpServers` में merge करें। Website पर one-click deeplink भी उपलब्ध। | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | `mcpServers` में merge करें। Edit के बाद Claude Desktop restart करें। | +| **Cline / Roo Code / Kilo Code** | Cline MCP settings (Settings UI → MCP Servers → Edit) | वही `mcpServers` block। | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | वही `mcpServers` block। | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (auto-merges)। | +| **OpenClaw** | OpenClaw MCP config | वही `mcpServers` block, या गहरे [memory plugin](../integrations/openclaw/) का उपयोग करें। | +| **Codex CLI (केवल MCP)** | `.codex/config.toml` | TOML shape: `codex mcp add agentmemory -- npx -y @agentmemory/mcp`, या manually `[mcp_servers.agentmemory]` जोड़ें। | +| **Codex CLI (पूर्ण plugin)** | Codex plugin marketplace | `codex plugin marketplace add rohitg00/agentmemory` फिर `codex plugin add agentmemory@agentmemory`। MCP + 6 lifecycle hooks (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skills register करता है। Codex Desktop पर, [openai/codex#16430](https://github.com/openai/codex/issues/16430) land होने तक `agentmemory connect codex --with-hooks` भी चलाएँ — plugin hooks वर्तमान में वहाँ silent हैं। | +| **OpenCode (केवल MCP)** | `opencode.json` | अलग shape — top-level `mcp` key, command array के रूप में: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`। | +| **OpenCode (पूर्ण plugin)** | `plugin/opencode/` | Session lifecycle, messages, tools, errors को कवर करने वाले 22 auto-capture hooks। दो slash commands (`/recall`, `/remember`)। `plugin/opencode/` को अपने OpenCode workspace में copy करें और plugin entry को `opencode.json` में जोड़ें। पूरी hook table + gap analysis के लिए [`plugin/opencode/README.md`](../plugin/opencode/README.md) देखें। | +| **pi** | `~/.pi/agent/extensions/agentmemory` | [`integrations/pi`](../integrations/pi/) copy करें और pi restart करें। | +| **Hermes Agent** | `~/.hermes/config.yaml` | गहरे [memory provider plugin](../integrations/hermes/) का उपयोग `memory.provider: agentmemory` के साथ करें। | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` standard `mcpServers` block लिखता है। Hook payload Claude Code के साथ field-compatible है, इसलिए मौजूदा 12-hook scripts modification के बिना काम करते हैं — उन्हें उसी `settings.json` के `hooks` section के माध्यम से जोड़ें। | +| **Antigravity** (Gemini CLI को replace करता है) | `mcp_config.json` (Antigravity की User dir में) | `agentmemory connect antigravity` standard `mcpServers` block लिखता है। macOS: `~/Library/Application Support/Antigravity/User/`। Linux: `~/.config/Antigravity/User/`। 2026-06-18 Gemini CLI sunset के बाद उपयोग करें। | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` user-level config लिखता है। Workspace overrides आपके code के बगल में `.kiro/settings/mcp.json` में जाते हैं। | +| **Goose** | Goose MCP settings UI | वही `mcpServers` block। | +| **Aider** | n/a | REST API से सीधे बात करें: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`। | +| **कोई भी एजेंट (32+)** | n/a | `npx skillkit install agentmemory` host को auto-detect करता है और merge करता है। | + +**Sandboxed MCP क्लाइंट्स** (Flatpak / Snap / प्रतिबंधात्मक containers) जो host के `localhost` तक नहीं पहुँच सकते: `env` block में `"AGENTMEMORY_FORCE_PROXY": "1"` भी set करें, और `AGENTMEMORY_URL` को एक ऐसे route पर point करें जिस तक sandbox वास्तव में पहुँच सकता है (जैसे आपका LAN IP)। + +### Programmatic access (Python / Rust / Node) + +agentmemory अपने core operations को iii functions के रूप में register करता है (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`)। iii SDK वाली कोई भी भाषा उन्हें `ws://localhost:49134` पर सीधे call कर सकती है — प्रति भाषा अलग REST क्लाइंट नहीं। + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +कार्यशील उदाहरण: [`examples/python/`](../examples/python/) (quickstart + observation/recall flow)। iii runtime के बिना hosts के लिए REST `:3111` पर उपलब्ध रहता है। + +### Source से + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +यह agentmemory को local `iii-engine` के साथ शुरू करता है अगर `iii` पहले से installed है, या Docker उपलब्ध होने पर Docker Compose पर fallback करता है। REST, streams, और व्यूअर default रूप से `127.0.0.1` से bind करते हैं। + +`iii-engine` मैनुअली इंस्टॉल करें। **agentmemory वर्तमान में `iii-engine` को `v0.11.2` पर pin करता है** — `v0.11.6` एक नया sandbox-everything-via-`iii worker add` model introduce करता है जिसके लिए agentmemory को अभी refactor नहीं किया गया है। Refactor land होने के बाद pin हटा दी जाती है। अगर आपने sandbox model पर मैनुअली migrate किया है तो `AGENTMEMORY_III_VERSION=` से override करें। + +- **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:** `aarch64-apple-darwin` को `x86_64-apple-darwin` के साथ बदलें +- **Linux x64:** `x86_64-unknown-linux-gnu` के साथ बदलें +- **Linux arm64:** `aarch64-unknown-linux-gnu` के साथ बदलें +- **Windows:** [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2) से `iii-x86_64-pc-windows-msvc.zip` download करें, `iii.exe` extract करें, PATH में जोड़ें + +या Docker का उपयोग करें (bundled `docker-compose.yml` `iiidev/iii:0.11.2` खींचता है)। पूर्ण docs: [iii.dev/docs](https://iii.dev/docs)। + +### Windows + +agentmemory Windows 10/11 पर चलता है, लेकिन केवल Node.js package पर्याप्त नहीं है — आपको एक background process के रूप में `iii-engine` runtime (एक अलग native binary) भी चाहिए। आधिकारिक upstream installer एक `sh` script है और आज कोई PowerShell installer या scoop/winget package नहीं है, इसलिए Windows users के पास दो रास्ते हैं: + +**विकल्प A — Prebuilt Windows binary (अनुशंसित):** + +```powershell +# 1. अपने browser में https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 खोलें +# (हम v0.11.2 पर pin करते हैं जब तक agentmemory नए sandbox +# model के लिए refactor नहीं हो जाता जो engine v0.11.6+ की आवश्यकता है) +# 2. iii-x86_64-pc-windows-msvc.zip download करें +# (या ARM machine पर हैं तो iii-aarch64-pc-windows-msvc.zip) +# 3. PATH पर कहीं iii.exe extract करें, या यहाँ रखें: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory उस location को automatically check करता है) +# 4. Verify करें: +iii --version +# Print होना चाहिए: 0.11.2 + +# 5. फिर agentmemory को सामान्य की तरह चलाएँ: +npx -y @agentmemory/agentmemory +``` + +**विकल्प B — Docker Desktop:** + +```powershell +# 1. Windows के लिए Docker Desktop install करें +# 2. Docker Desktop शुरू करें और सुनिश्चित करें कि engine चल रहा है +# 3. agentmemory चलाएँ — यह bundled compose file को auto-start करेगा: +npx -y @agentmemory/agentmemory +``` + +**विकल्प C — केवल standalone MCP (कोई engine नहीं):** अगर आपको केवल अपने agent के लिए MCP tools चाहिए और REST API, व्यूअर, या cron jobs की ज़रूरत नहीं है, तो engine को पूरी तरह से skip करें: + +```powershell +npx -y @agentmemory/agentmemory mcp +# या shim package के माध्यम से: +npx -y @agentmemory/mcp +``` + +**Windows के लिए diagnostics:** अगर `npx @agentmemory/agentmemory` fail करता है, तो वास्तविक engine stderr देखने के लिए `--verbose` के साथ फिर से चलाएँ। सामान्य failure modes: + +| लक्षण | समाधान | +|---|---| +| `iii-engine process started` फिर `did not become ready within 15s` | Engine startup पर crashed — `--verbose` के साथ फिर से चलाएँ, stderr check करें | +| `Could not start iii-engine` | न तो `iii.exe` न ही Docker installed है। ऊपर विकल्प A या B देखें | +| Port conflict | `netstat -ano \| findstr :3111` से देखें कि क्या bind है, फिर उसे kill करें या `--port ` का उपयोग करें | +| Docker installed होने पर भी Docker fallback skip हो रहा है | सुनिश्चित करें कि Docker Desktop वास्तव में चल रहा है (system tray icon) | + +> नोट: iii **engine** एक prebuilt binary है, cargo crate नहीं — इसे `cargo install` से install करने की कोशिश न करें। (iii **SDKs** crates.io, npm, और PyPI पर publish हैं, लेकिन agentmemory को उनकी ज़रूरत नहीं है।) समर्थित engine install methods, सभी v0.11.2 पर pinned: ऊपर वाला prebuilt v0.11.2 binary, version pin **के साथ** upstream `sh` install script `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux), और Docker image `iiidev/iii:0.11.2`। केवल `install.sh | sh` **latest** engine install करता है, जिसे agentmemory support नहीं करता — हमेशा `VERSION=0.11.2` पास करें। सबसे आसान: बस `npx @agentmemory/agentmemory` चलाएँ, जो pinned engine को आपके लिए `~/.agentmemory/bin` में ले आता है। + +--- + +

Deploy

+ +Managed hosts के लिए one-click templates। प्रत्येक एक self-contained +Dockerfile ship करता है जो npm से `@agentmemory/agentmemory` खींचता है +और आधिकारिक `iiidev/iii` Docker Hub image से iii engine binary को +copy करता है — pre-built agentmemory image की आवश्यकता नहीं। Persistent +storage `/data` पर mount होती है; first-boot entrypoint npm-bundled +iii config (जो `127.0.0.1` से bind करती है) को एक deploy-tuned config +से overwrite करता है जो `0.0.0.0` से bind करती है और absolute `/data` +paths का उपयोग करती है, HMAC secret generate करती है, फिर agentmemory +CLI को exec करने से पहले `gosu` के माध्यम से privileges को `root` से +`node` पर drop करती है। + +

+ Deploy to fly.io + Deploy to Railway +

+ +Render का one-click deploy button repository root पर `render.yaml` की आवश्यकता रखता है, +जिसे हम जानबूझकर साफ़ रखते हैं। In-repo blueprint पर manually point करने के लिए +[`deploy/render/`](../deploy/render/README.md) में documented Render Blueprint flow का उपयोग करें। + +पूर्ण setup विवरण (HMAC capture, viewer SSH tunnel, +rotation, backup, cost floors) [`deploy/`](../deploy/README.md) में रहते हैं: + +- [`deploy/fly`](../deploy/fly/README.md) — `auto_stop_machines = "stop"` के साथ + single machine; सबसे सस्ता idle। +- [`deploy/railway`](../deploy/railway/README.md) — Hobby plan flat fee, + dashboard में volume। +- [`deploy/render`](../deploy/render/README.md) — Blueprint flow, + paid plans पर automatic disk snapshots। +- [`deploy/coolify`](../deploy/coolify/README.md) — अपने स्वयं के VPS पर + [Coolify](https://coolify.io/self-hosted) के माध्यम से self-hosted; वही Docker + Compose stack, आप host और data के मालिक हैं। + +केवल port `3111` publish किया जाता है। `3113` पर viewer container के अंदर +loopback से bound रहता है — हर template का README उस तक पहुँचने के लिए +SSH-tunnel pattern को document करता है। + +--- + +

Why agentmemory

+ +हर coding agent सेशन समाप्त होने पर सब कुछ भूल जाता है। आप हर सेशन के पहले 5 मिनट अपने stack को फिर से समझाने में बर्बाद करते हैं। agentmemory पृष्ठभूमि में चलता है और इसे पूरी तरह से समाप्त कर देता है। + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### बिल्ट-इन agent memory से तुलना + +हर AI coding agent बिल्ट-इन memory के साथ ship होता है — Claude Code में `MEMORY.md` है, Cursor में notepads हैं, Cline में memory bank है। ये sticky notes की तरह काम करते हैं। agentmemory उन sticky notes के पीछे का searchable database है। + +| | बिल्ट-इन (CLAUDE.md) | agentmemory | +|---|---|---| +| Scale | 200-line cap | असीमित | +| खोज | सब कुछ context में load करता है | BM25 + vector + graph (केवल top-K) | +| Token cost | 240 observations पर 22K+ | ~1,900 tokens (92% कम) | +| Cross-agent | प्रति-agent फाइलें | MCP + REST (कोई भी agent) | +| Coordination | कोई नहीं | Leases, signals, actions, routines | +| Observability | फाइलें मैनुअल पढ़ें | :3113 पर real-time viewer | + +--- + +

How It Works

+ +### Memory Pipeline + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4-Tier Memory Consolidation + +मानव मस्तिष्क memory को कैसे process करता है उससे प्रेरित — sleep consolidation से बहुत अलग नहीं। + +| Tier | क्या | Analogy | +|------|------|---------| +| **Working** | Tool use से raw observations | Short-term memory | +| **Episodic** | संकुचित session summaries | "क्या हुआ" | +| **Semantic** | निकाले गए facts और patterns | "मैं क्या जानता हूँ" | +| **Procedural** | Workflows और decision patterns | "कैसे करें" | + +Memories समय के साथ decay होती हैं (Ebbinghaus curve)। बार-बार access की जाने वाली memories मज़बूत होती हैं। पुरानी memories auto-evict होती हैं। Contradictions detect और resolve होती हैं। + +### क्या Capture होता है + +| Hook | Captures | +|------|----------| +| `SessionStart` | Project path, session ID | +| `UserPromptSubmit` | User prompts (privacy-filtered) | +| `PreToolUse` | File access patterns + enriched context | +| `PostToolUse` | Tool name, input, output | +| `PostToolUseFailure` | Error context | +| `PreCompact` | Compaction से पहले memory को re-inject करता है | +| `SubagentStart/Stop` | Sub-agent lifecycle | +| `Stop` | End-of-session summary | +| `SessionEnd` | Session complete marker | + +### मुख्य क्षमताएँ + +| क्षमता | विवरण | +|---|---| +| **Automatic capture** | हर tool use hooks के माध्यम से record होता है — शून्य manual effort | +| **Semantic search** | RRF fusion के साथ BM25 + vector + knowledge graph | +| **Memory evolution** | Versioning, supersession, relationship graphs | +| **Auto-forgetting** | TTL expiry, contradiction detection, importance eviction | +| **Privacy first** | API keys, secrets, `` tags storage से पहले strip होते हैं | +| **Self-healing** | Circuit breaker, provider fallback chain, health monitoring | +| **Claude bridge** | MEMORY.md के साथ bi-directional sync | +| **Knowledge graph** | Entity extraction + BFS traversal | +| **Team memory** | Team members के बीच namespaced shared + private | +| **Citation provenance** | किसी भी memory को source observations तक trace करें | +| **Git snapshots** | Memory state को version, rollback, और diff करें | + +--- + + + +तीन signals को combine करने वाला triple-stream retrieval: + +| Stream | यह क्या करता है | कब | +|---|---|---| +| **BM25** | Synonym expansion के साथ stemmed keyword matching | हमेशा on | +| **Vector** | Dense embeddings पर cosine similarity | Embedding provider configured | +| **Graph** | Entity matching के माध्यम से knowledge graph traversal | Query में entities detected | + +Reciprocal Rank Fusion (RRF, k=60) के साथ fuse होता है और session-diversified होता है (प्रति session max 3 results)। + +BM25 box से बाहर ही Greek, Cyrillic, Hebrew, Arabic, और accented Latin को tokenize करता है। Chinese / Japanese / Korean memories के लिए, CJK runs को word-level tokens में split करने के लिए optional segmenters install करें (`npm install @node-rs/jieba tiny-segmenter`); उनके बिना, agentmemory soft-fall back होकर whole-run tokenization पर जाता है और stderr पर एक-बार hint print करता है। + +### Embedding providers + +agentmemory आपके provider को auto-detect करता है। सर्वोत्तम परिणामों के लिए, local embeddings install करें (free): + +```bash +npm install @xenova/transformers +``` + +| Provider | Model | Cost | नोट्स | +|---|---|---|---| +| **Local (अनुशंसित)** | `all-MiniLM-L6-v2` | Free | Offline, BM25-only पर +8pp recall | +| Gemini | `gemini-embedding-001` | Free tier | 100+ भाषाएँ, 768/1536/3072 dims (MRL), 2048-token input। `text-embedding-004` को replace करता है ([deprecated, 14 जनवरी 2026 को shutdown](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | $0.02/1M | उच्चतम quality | +| Voyage AI | `voyage-code-3` | Paid | Code के लिए optimized | +| Cohere | `embed-english-v3.0` | Free trial | General purpose | +| OpenRouter | कोई भी model | भिन्न | Multi-model proxy | + +--- + +

MCP Server

+ +53 tools, 6 resources, 3 prompts, और 4 skills — किसी भी agent के लिए सबसे व्यापक MCP memory toolkit। + +> **MCP shim बनाम full server:** published `@agentmemory/mcp` package एक thin shim है। यह full 51-tool surface को **केवल तभी expose करता है जब यह `AGENTMEMORY_URL` के माध्यम से चल रहे agentmemory server तक पहुँच सके** (proxy mode)। कोई पहुँच योग्य server न होने पर, shim 7-tool local set (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`) पर fallback करता है। `AGENTMEMORY_TOOLS=core|all` env var एक *server-side* flag है — shim के `env` block में set करने का कोई असर नहीं। अगर आप Cursor / OpenCode / Gemini CLI में केवल 7 tools देखते हैं, तो `npx @agentmemory/agentmemory` (या Docker stack) शुरू करें और `AGENTMEMORY_URL=http://localhost:3111` set करें। + +### 51 Tools + +
+Core tools (हमेशा उपलब्ध) + +| Tool | विवरण | +|------|-------------| +| `memory_recall` | पिछले observations खोजें | +| `memory_compress_file` | Structure preserve करते हुए markdown files compress करें | +| `memory_save` | एक insight, decision, या pattern save करें | +| `memory_patterns` | Recurring patterns detect करें | +| `memory_smart_search` | Hybrid semantic + keyword search | +| `memory_file_history` | विशिष्ट files के बारे में पिछले observations | +| `memory_sessions` | Recent sessions list करें | +| `memory_timeline` | Chronological observations | +| `memory_profile` | Project profile (concepts, files, patterns) | +| `memory_export` | सभी memory data export करें | +| `memory_relations` | Relationship graph query करें | + +
+ +
+Extended tools (कुल 51 — AGENTMEMORY_TOOLS=all set करें) + +| Tool | विवरण | +|------|-------------| +| `memory_patterns` | Recurring patterns detect करें | +| `memory_timeline` | Chronological observations | +| `memory_relations` | Relationship graph query करें | +| `memory_graph_query` | Knowledge graph traversal | +| `memory_consolidate` | 4-tier consolidation चलाएँ | +| `memory_claude_bridge_sync` | MEMORY.md के साथ sync करें | +| `memory_team_share` | Team members के साथ share करें | +| `memory_team_feed` | हाल ही में shared items | +| `memory_audit` | Operations का audit trail | +| `memory_governance_delete` | Audit trail के साथ delete करें | +| `memory_snapshot_create` | Git-versioned snapshot | +| `memory_action_create` | Dependencies के साथ work items create करें | +| `memory_action_update` | Action status update करें | +| `memory_frontier` | Priority द्वारा ranked unblocked actions | +| `memory_next` | Single most important next action | +| `memory_lease` | Exclusive action leases (multi-agent) | +| `memory_routine_run` | Workflow routines instantiate करें | +| `memory_signal_send` | Inter-agent messaging | +| `memory_signal_read` | Receipts के साथ messages पढ़ें | +| `memory_checkpoint` | External condition gates | +| `memory_mesh_sync` | Instances के बीच P2P sync | +| `memory_sentinel_create` | Event-driven watchers | +| `memory_sentinel_trigger` | Sentinels externally fire करें | +| `memory_sketch_create` | Ephemeral action graphs | +| `memory_sketch_promote` | Permanent पर promote करें | +| `memory_crystallize` | Action chains compact करें | +| `memory_diagnose` | Health checks | +| `memory_heal` | Stuck state को auto-fix करें | +| `memory_facet_tag` | Dimension:value tags | +| `memory_facet_query` | Facet tags द्वारा query करें | +| `memory_verify` | Provenance trace करें | + +
+ +### 6 Resources · 3 Prompts · 4 Skills + +| प्रकार | नाम | विवरण | +|------|------|-------------| +| Resource | `agentmemory://status` | Health, session count, memory count | +| Resource | `agentmemory://project/{name}/profile` | Per-project intelligence | +| Resource | `agentmemory://memories/latest` | नवीनतम 10 active memories | +| Resource | `agentmemory://graph/stats` | Knowledge graph statistics | +| Prompt | `recall_context` | Search + context messages return करें | +| Prompt | `session_handoff` | Agents के बीच handoff data | +| Prompt | `detect_patterns` | Recurring patterns analyze करें | +| Skill | `/recall` | Memory खोजें | +| Skill | `/remember` | Long-term memory में save करें | +| Skill | `/session-history` | हाल के session summaries | +| Skill | `/forget` | Observations/sessions delete करें | + +### Standalone MCP + +Full server के बिना चलाएँ — किसी भी MCP client के लिए। इनमें से कोई भी काम करता है: + +```bash +npx -y @agentmemory/agentmemory mcp # canonical (हमेशा उपलब्ध) +npx -y @agentmemory/mcp # shim package alias +``` + +या अपने agent की MCP config में जोड़ें: + +अधिकांश agents (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +`agentmemory` entry को file को replace करने के बजाय अपने host के मौजूदा `mcpServers` object में merge करें। होस्ट के `localhost` तक नहीं पहुँच सकने वाले sandboxed clients के लिए, env block में `"AGENTMEMORY_FORCE_PROXY": "1"` जोड़ें और `AGENTMEMORY_URL` को एक ऐसे route पर set करें जिस तक sandbox पहुँच सकता है। + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Plugin file को repo से copy करें: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Real-Time Viewer

+ +Port `3113` पर auto-start होता है। Live observation stream, session explorer, memory browser, knowledge graph visualization, और health dashboard। + +```bash +open http://localhost:3113 +``` + +व्यूअर server default रूप से `127.0.0.1` से bind होता है। REST-served `/agentmemory/viewer` endpoint सामान्य `AGENTMEMORY_SECRET` bearer-token नियमों का पालन करता है। CSP headers per-response script nonce का उपयोग करते हैं और inline handler attributes को disable करते हैं (`script-src-attr 'none'`)। + +--- + +

iii Console

+ +`:3113` पर viewer दिखाता है कि आपके agent ने क्या **याद रखा**। [iii console](https://iii.dev/docs/console) दिखाता है कि आपके agent ने क्या **किया** — हर memory op एक OpenTelemetry trace के रूप में, हर KV entry editable, हर function invocable, हर stream tappable। एक ही memory पर दो windows: एक product-shaped, एक engine-shaped। + +`memory_smart_search` को fire होते देखें और BM25 scan → embedding lookup → RRF fusion → reranker को waterfall के रूप में देखें। KV browser में stuck consolidation timer को edit करें। `PostToolUse` hook को tweaked payload के साथ replay करें। WebSocket stream को pin करें और observations को live land होते देखें। + +agentmemory इसे free में ship करता है क्योंकि हर function, trigger, state scope, और stream एक iii primitive है — कुछ भी custom नहीं, instrument करने के लिए कुछ नहीं। + +

+ iii console Workers page — connected workers including agentmemory instances with live function counts and runtime metadata +
+ Workers page: हर connected worker — agentmemory स्वयं सहित — PID, function count, runtime, और last-seen के साथ। +

+ +**पहले से installed।** Console `iii` के साथ ship होता है — कोई अलग installer नहीं। + +**agentmemory के साथ launch करें:** + +```bash +# agentmemory viewer port 3113 रखता है, तो console को 3114 पर चलाएँ। +# Engine REST (3111), WebSocket (3112), और bridge (49134) defaults agentmemory से match करते हैं। +iii console --port 3114 +``` + +फिर `http://localhost:3114` खोलें। Experimental architecture-graph page के लिए `--enable-flow` जोड़ें। + +केवल तभी engine endpoints override करें जब आपने उन्हें move किया हो: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**Console से आप क्या कर सकते हैं:** + +| Page | इसके लिए उपयोग करें | +|------|-----------| +| **Workers** | हर connected worker और उसके live metrics देखें — agentmemory worker सहित। | +| **Functions** | agentmemory के किसी भी function को सीधे JSON payload के साथ invoke करें — client जोड़े बिना `memory.recall`, `memory.consolidate`, `graph.query` test करने के लिए उपयोगी। | +| **Triggers** | HTTP, cron, event, और state triggers replay करें — consolidation cron को manually fire करें, HTTP route retry करें, एक state change emit करें। | +| **States** | Full CRUD के साथ KV browser — sessions, memory slots, lifecycle timers, embeddings index — values को in place edit करें। | +| **Streams** | Memory writes, hook events, और observation updates के लिए live WebSocket monitor क्योंकि वे iii streams से बहते हैं। | +| **Queues** | Durable queue topics + dead-letter management। Failed embedding / compression jobs को replay या drop करें। | +| **Traces** | OpenTelemetry waterfall / flame / service-breakdown views। `trace_id` से filter करें ताकि देख सकें कि एक `memory.search` ने वास्तव में कौन से functions, DB calls, और embedding requests produce किए। | +| **Logs** | Trace/span IDs से correlated और filtered structured OTEL logs। | +| **Config** | Runtime configuration — देखें कि आपका engine किन workers, providers, और ports के साथ चल रहा है। | +| **Flow** | (Optional, `--enable-flow`) हर worker, trigger, और stream का interactive architecture graph। | + +

+ iii console trace waterfall view showing per-span duration +
+ Traces: हर memory operation के लिए waterfall / flame / service breakdown। +

+ +**Traces पहले से on हैं:** + +`iii-config.yaml` `iii-observability` worker enabled (`exporter: memory`, `sampling_ratio: 1.0`, metrics + logs) के साथ ship होता है। कोई extra config की ज़रूरत नहीं — जैसे ही agentmemory शुरू होता है, हर memory operation एक trace span और एक structured log emit करता है जिसे console पढ़ सकता है। + +अगर आप इसके बजाय Jaeger/Honeycomb/Grafana Tempo पर export करना चाहते हैं, तो `exporter: memory` को `exporter: otlp` में बदलें और iii के observability docs के अनुसार collector endpoint set करें। + +> **ध्यान दें:** console पर कोई auth enforce नहीं है — इसे `127.0.0.1` (default) से bound रखें और इसे कभी publicly expose न करें। + +--- + +

Powered by iii

+ +agentmemory **पहले से एक चल रहा [iii](https://iii.dev) instance है**। Functions, triggers, KV state, streams, OTEL traces — यह सब iii primitives हैं। आपने Postgres, Redis, Express, pm2, या Prometheus install नहीं किया, क्योंकि iii उन्हें replace करता है। + +इसका मतलब है कि एक और कमांड agentmemory को एक पूरी नई capability के साथ extend करती है। + +### एक command के साथ agentmemory को extend करें + +```bash +iii worker add iii-pubsub # memory writes को हर connected instance पर fan out करें +iii worker add iii-cron # scheduled consolidation, decay sweeps, snapshot rotation +iii worker add iii-queue # embedding + compression jobs के लिए durable retries +iii worker add iii-observability # हर memory op पर OTEL traces (default on) +iii worker add iii-sandbox # recalled code को isolated microVM के अंदर चलाएँ +iii worker add iii-database # एक SQL-backed state adapter में swap करें +iii worker add mcp # agentmemory MCP के साथ-साथ generic MCP host +``` + +प्रत्येक `iii worker add` उसी engine में नए functions और triggers register करता है जिस पर agentmemory पहले से चल रहा है। Viewer और console उन्हें तुरंत pick करते हैं — कोई reload नहीं, कोई नया integration नहीं, कोई नया container नहीं। + +| `iii worker add` | agentmemory के ऊपर आपको क्या मिलता है | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | Multi-instance memory: हर `remember` fan out होती है, हर `search` union पढ़ता है | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Scheduled lifecycle — रात की consolidation, साप्ताहिक snapshots, fixed clock पर decay | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | Durable retries: failed embedding + compression jobs restart से बचते हैं, कोई lost observations नहीं | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | हर function पर OTEL traces, metrics, logs — दिन एक से `iii-config.yaml` में wired | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | `memory_recall` से निकला code throwaway VM के अंदर चलता है, आपके shell में नहीं | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | जब आप in-memory KV defaults से बाहर निकलते हैं तो SQL-backed state adapter | +| [`mcp`](https://workers.iii.dev/workers/mcp) | agentmemory के साथ-साथ extra MCP servers खड़े करें, वही engine share करें | + +Full registry: [workers.iii.dev](https://workers.iii.dev)। वहाँ हर worker उन्हीं primitives के माध्यम से compose करता है जिनका agentmemory उपयोग करता है — और आपके पास पहले से जो agentmemory है, वह उनमें से एक है। + +### iii क्या replace करता है + +| Traditional stack | agentmemory उपयोग करता है | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + in-memory vector index | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | iii engine worker supervision | +| Prometheus / Grafana | iii OTEL + health monitor | +| Custom plugin systems | `iii worker add ` | + +**118 source files · ~21,800 LOC · 950+ tests · 123 functions · 34 KV scopes** — सब कुछ तीन primitives पर। कोई `agentmemory plugin install` नहीं। Plugin system iii स्वयं है। + +--- + +

Configuration

+ +### LLM Providers + +agentmemory आपके environment से auto-detect करता है। Default रूप से, जब तक आप एक provider configure नहीं करते या Claude subscription fallback में explicitly opt in नहीं करते, कोई LLM calls नहीं की जातीं। + +| Provider | Config | नोट्स | +|----------|--------|-------| +| **No-op (default)** | कोई config की ज़रूरत नहीं | LLM-backed compress/summarize DISABLED है। Synthetic BM25 compression + recall अभी भी काम करते हैं। अगर आप पहले Claude-subscription fallback पर निर्भर थे तो नीचे `AGENTMEMORY_ALLOW_AGENT_SDK` देखें। | +| Anthropic API | `ANTHROPIC_API_KEY` | Per-token billing | +| MiniMax | `MINIMAX_API_KEY` | Anthropic-compatible | +| Gemini | `GEMINI_API_KEY` | Embeddings भी enable करता है | +| OpenRouter | `OPENROUTER_API_KEY` | कोई भी model | +| Claude subscription fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | केवल opt-in। `@anthropic-ai/claude-agent-sdk` sessions spawn करता है — पहले unbounded Stop-hook recursion का कारण था तो यह अब default नहीं है। | + +### Cost-aware model selection + +Background compression हर observation पर चलता है, इसलिए model choice monthly spend को meaningfully बदलता है। Captured workload data: 635 requests / 888K tokens / 35 hours of active use, 2026-05-23 pricing पर तीन OpenRouter models पर चलाया गया। + +| Tier | Model | Input / 1M | Output / 1M | Captured 35h के लिए cost | नोट्स | +|------|-------|------------|-------------|---------------------------|-------| +| अनुशंसित | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | Sonnet से ~10× कम cost पर solid compression + summarization quality। | +| अनुशंसित | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | पुराना लेकिन केवल-compression workloads के लिए अभी भी ठीक। | +| अनुशंसित | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | अगर आपके sessions भारी रूप से code-shaped हैं तो strong code reasoning। | +| Premium | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | High quality लेकिन always-on background work के लिए महंगा। | +| Premium | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | Sonnet के समान tier। | +| बचें | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | Reasoning-class model; compression के लिए massive overspend। | + +जब `OPENROUTER_MODEL` premium-tier pattern से match करता है तो agentmemory एक runtime warning print करता है। जब आप informed choice कर लें तो silence करने के लिए `AGENTMEMORY_SUPPRESS_COST_WARNING=1` set करें। + +Memory work के लिए quality बनाम cost tradeoff: compression एक summarization task है जिसमें अपेक्षाकृत loose quality bars हैं (agent summary को re-read करता है, user नहीं)। DeepSeek-V4-Pro / Qwen3-Coder इस task पर Sonnet से rounding error के भीतर land होते हैं जबकि ~10× कम cost में। Premium-tier models को उन queries के लिए save करें जिन्हें आप सीधे पढ़ते हैं। + +Sources: [Sonnet 4.6 के लिए OpenRouter pricing](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing), [DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro), [DeepSeek pricing नोट्स](https://api-docs.deepseek.com/quick_start/pricing/)। + +### Multi-agent memory (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +Multi-agent setups में जहाँ कई roles एक agentmemory server share करते हैं (architect / developer / reviewer / researcher / support-agent), `AGENT_ID` हर write को उस role से tag करता है जिसने इसे किया। `AGENTMEMORY_AGENT_SCOPE` यह control करता है कि recall उस tag के द्वारा filter करता है या नहीं। + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" +``` + +दो modes: + +| Mode | Writes को tag करें | Recall filter करें | कब उपयोग करें | +|------|------------|---------------|-------------| +| `shared` (default) | हाँ | नहीं | Audit trail के साथ cross-agent context। Architect देख सकता है कि developer ने क्या note किया, लेकिन हर row record करती है कि किसने कहा। | +| `isolated` | हाँ | हाँ | सख्त separation। Architect कभी developer के observations / memories / sessions नहीं देखता। | + +जब `AGENT_ID` set होता है तो क्या tagged होता है: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`। Role `api::session::start` → `mem::observe` → `mem::compress` → KV से flow करता है। + +Isolated mode में क्या filter होता है: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`। प्रत्येक endpoint per-request override के लिए `?agentId=` और env scope से पूरी तरह से opt out करने के लिए `?agentId=*` accept करता है। `/memories` AGENT_ID से पहले के memories को surface करने के लिए `?includeOrphans=true` भी accept करता है जिनकी `agentId` undefined है। + +SDK / REST layer पर per-call override: हर mutating endpoint (`/session/start`, `/remember`) request body में एक `agentId` field accept करता है जो env से जीतता है। एक server process के माध्यम से कई roles को route करने वाले runtimes के लिए उपयोगी। + +जब `AGENT_ID` unset होता है, तो memory unscoped रहती है (legacy behavior, कोई tags नहीं, कोई filters नहीं)। + +### Ports + +agentmemory + iii-engine default रूप से चार ports पर bind होते हैं। अगर एक restart `port in use` के साथ fail होता है, तो यह table बताती है कि किस process को देखना है। + +| Port | Process | उद्देश्य | Env override | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | Internal streams worker (agentmemory + viewer द्वारा consumed) | `III_STREAMS_PORT` | +| `3113` | agentmemory | Real-time viewer (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — workers यहाँ register होते हैं, OTel telemetry यहाँ से flow होती है | `III_ENGINE_URL` (full URL, default `ws://localhost:49134`) | + +Crashed run के बाद ports bound रहने पर stale-process cleanup: + +```bash +# macOS / Linux — हर port पर जो भी है उसे ढूँढ़ें और kill करें +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` graceful shutdown पर worker और engine pidfile दोनों को साफ़ रूप से reap करता है। ऊपर का manual cleanup केवल post-crash case के लिए है जहाँ कोई भी pidfile पीछे नहीं छोड़ी गई। + +### Config File + +हर shell में variables export करने के बजाय agentmemory runtime configuration को `~/.agentmemory/.env` में रखें। अगर viewer `export ANTHROPIC_API_KEY=...` जैसा setup hint दिखाता है, तो इसे `export` prefix के बिना इस file में `ANTHROPIC_API_KEY=...` के रूप में copy करें, फिर agentmemory restart करें। + +Process environment variables अभी भी काम करते हैं और file में values पर precedence लेते हैं। + +Windows पर, वही file `%USERPROFILE%\.agentmemory\.env` पर रहती है: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +API key के बजाय Claude Code Pro/Max subscription के साथ test करने के लिए, explicitly opt in करें: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +अगर आप graph या consolidation features चाहते हैं तो उसी file में उन्हें on करें: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### Environment Variables + +`~/.agentmemory/.env` बनाएँ: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +Port `3111` पर 124 endpoints। REST API default रूप से `127.0.0.1` से bind होता है। `AGENTMEMORY_SECRET` set होने पर protected endpoints `Authorization: Bearer ` की आवश्यकता रखते हैं, और mesh sync endpoints दोनों peers पर `AGENTMEMORY_SECRET` की आवश्यकता रखते हैं। + +
+मुख्य endpoints + +| Method | Path | विवरण | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | Health check (हमेशा public) | +| `POST` | `/agentmemory/session/start` | Session शुरू करें + context प्राप्त करें | +| `POST` | `/agentmemory/session/end` | Session समाप्त करें | +| `POST` | `/agentmemory/observe` | Observation capture करें | +| `POST` | `/agentmemory/smart-search` | Hybrid search | +| `POST` | `/agentmemory/context` | Context generate करें | +| `POST` | `/agentmemory/remember` | Long-term memory में save करें | +| `POST` | `/agentmemory/forget` | Observations delete करें | +| `POST` | `/agentmemory/enrich` | File context + memories + bugs | +| `GET` | `/agentmemory/profile` | Project profile | +| `GET` | `/agentmemory/export` | सभी data export करें | +| `POST` | `/agentmemory/import` | JSON से import करें | +| `POST` | `/agentmemory/graph/query` | Knowledge graph query | +| `POST` | `/agentmemory/team/share` | Team के साथ share करें | +| `GET` | `/agentmemory/audit` | Audit trail | + +Full endpoint list: [`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Development

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 950+ tests +npm run test:integration # API tests (running services की आवश्यकता है) +``` + +**आवश्यकताएँ:** Node.js >= 20, [iii-engine](https://iii.dev/docs) या Docker + +

License

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.ja-JP.md b/READMEs/README.ja-JP.md new file mode 100644 index 0000000..979094f --- /dev/null +++ b/READMEs/README.ja-JP.md @@ -0,0 +1,1379 @@ +

+ agentmemory — AI コーディングエージェントのための永続メモリ +

+ +

+ + コーディングエージェントがすべてを記憶します。もう説明し直す必要はありません。 + Built on iii engine +
+ Claude Code、Cursor、Gemini CLI、Codex CLI、Hermes、OpenClaw、pi、OpenCode、そしてあらゆる MCP クライアントのための永続メモリ。 +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Design doc: 1200 stars / 172 forks on the gist +

+ +

+ この gist は Karpathy の LLM Wiki パターンを信頼度スコア、ライフサイクル管理、ナレッジグラフ、ハイブリッド検索で拡張します。agentmemory はその実装です。 +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ agentmemory demo +

+ +

+ インストール • + クイックスタート • + ベンチマーク • + 競合比較 • + エージェント • + 仕組み • + MCP • + ビューワー • + iii コンソール • + Powered by iii • + 設定 • + API +

+ +--- + +## インストール + +```bash +npm install -g @agentmemory/agentmemory # 一度のインストール — PATH 上に `agentmemory` が使えるようになる +# macOS/Linux のシステム Node で EACCES が出る場合は次を試してください: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # :3111 でメモリサーバーを起動 +agentmemory demo # サンプルセッションを投入してリコールを実証 +agentmemory connect claude-code # エージェントを接続 (他にも codex, cursor, gemini-cli, ...) +``` + +または `npx` で(インストール不要): + +```bash +npx @agentmemory/agentmemory +``` + +注意 — npx はバージョン単位でキャッシュします。素の `npx @agentmemory/agentmemory` が古いリリースを返す場合は、`npx -y @agentmemory/agentmemory@latest` で最新を強制するか、`rm -rf ~/.npm/_npx`(macOS/Linux。Windows では `%LOCALAPPDATA%\npm-cache\_npx` を削除)で一度キャッシュをクリアしてください。v0.9.16+ では初回 npx 実行時にインラインでグローバルインストールを促されるので、それ以降は素の `agentmemory` コマンドがどこでも動きます。 + +すべてのオプションは下の[クイックスタート](#quick-start)を参照。各エージェント固有の接続は[すべてのエージェントで動作](#works-with-every-agent)を参照。 + +--- + +

Works with every agent

+ +agentmemory は hooks、MCP、REST API をサポートするあらゆるエージェントで動作します。すべてのエージェントが同じメモリサーバーを共有します。 + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+ネイティブプラグイン + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+ネイティブプラグイン + 6 hooks + MCP +
+OpenClaw
+OpenClaw
+ネイティブプラグイン + MCP +
+Hermes
+Hermes
+ネイティブプラグイン + MCP +
+pi
+pi
+ネイティブプラグイン + MCP +
+OpenHuman
+OpenHuman
+ネイティブ Memory trait バックエンド +
+Cursor
+Cursor
+MCP サーバー +
+Gemini CLI
+Gemini CLI
+MCP サーバー +
+OpenCode
+OpenCode
+22 hooks + MCP + プラグイン +
+Cline
+Cline
+MCP サーバー +
+Goose
+Goose
+MCP サーバー +
+Kilo Code
+Kilo Code
+MCP サーバー +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP サーバー +
+Windsurf
+Windsurf
+MCP サーバー +
+Roo Code
+Roo Code
+MCP サーバー +
+ +

+ MCP または HTTP を話すあらゆるエージェントで動作。サーバー 1 つで、すべてのエージェントがメモリを共有。 +

+ +--- + +あなたは毎セッション、同じアーキテクチャを説明し直している。同じバグを何度も発見する。同じ好みを繰り返し教える。組み込みのメモリ(CLAUDE.md、.cursorrules)は 200 行で打ち止め、しかも古びていく。agentmemory がこれを解決します。バックグラウンドで静かにエージェントの動きを捕捉し、検索可能なメモリに圧縮し、次のセッションが始まるときに適切なコンテキストを注入します。コマンド 1 つ。エージェント間で動作します。 + +**何が変わるか:** セッション 1 で JWT 認証をセットアップ。セッション 2 でレート制限を依頼する。エージェントは既に、あなたの認証が `src/middleware/auth.ts` の jose ミドルウェアを使い、テストがトークン検証をカバーし、Edge 互換性のために jsonwebtoken ではなく jose を選んだことを知っています。説明のし直し不要。コピペ不要。エージェントはただ*知っている*。 + +```bash +npx @agentmemory/agentmemory +``` + +> **v0.9.0 新機能** — ランディングサイト [agent-memory.dev](https://agent-memory.dev) 公開、ファイルシステムコネクタ(`@agentmemory/fs-watcher`)、スタンドアロン MCP は実行中のサーバーへプロキシすることで hooks とビューワーが整合、削除パス全体で監査ポリシーをコード化、健康チェックは小さな Node プロセスで `memory_critical` を誤検知しなくなりました。詳細は [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18) を参照。 + +--- + +

Benchmarks

+ + + + + + +
+ +### 検索精度 + +**coding-agent-life-v1**(社内コーパス、サンドボックスで再現可能) + +| アダプタ | P@5 | R@5 | Top-5 ヒット率 | p50 レイテンシ | +|---|---|---|---|---| +| **agentmemory ハイブリッド** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep ベースライン | 0.267 | 0.967 | 15 / 15 | 0 ms | + +100% Top-5 ヒット率。同じ入力で grep ベースラインより **2.2×** 高い精度。タイプ別の詳細は [`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 問) + +| システム | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| BM25 のみのフォールバック | 86.2% | 94.6% | 71.5% | + + + +### トークン削減 + +| 方式 | トークン/年 | コスト/年 | +|---|---|---| +| フルコンテキスト貼付 | 19.5M+ | 不可能(コンテキストウィンドウ超過) | +| LLM 要約 | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + ローカル埋め込み | ~170K | **$0** | + +
+ +> 埋め込みモデル:`all-MiniLM-L6-v2`(ローカル、無料、API キー不要)。詳細レポート:[`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md)、[`benchmark/QUALITY.md`](../benchmark/QUALITY.md)、[`benchmark/SCALE.md`](../benchmark/SCALE.md)。競合比較:[`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory vs mem0、Letta、Khoj、claude-mem、Hippo。 + +**ローカルで再現:** [`eval/README.md`](../eval/README.md) — LongMemEval `_s`(公開 500 問)+ `coding-agent-life-v1`(社内 15 セッションコーパス)向けのアダプタプラガブルハーネス。Grep / vector / agentmemory アダプタを並べてスコアリングし、NDJSON 出力、公開スコアカードは [`docs/benchmarks/`](../docs/benchmarks/) に掲載。 + +**[codegraph](https://github.com/colbymchenry/codegraph)、[Understand Anything](https://github.com/Lum1104/Understand-Anything)、[Graphify](https://github.com/safishamsi/graphify) と組み合わせて使えます。** コードグラフのインデックス、マルチエージェントビルドパイプライン、ドキュメント / PDF / 画像 / 動画にまたがる広範なナレッジグラフ。agentmemory が作業を覚え、これら 3 つのプロジェクトがコンテキストレイヤーの残りを照らします。レシピと質問ルーティング表:[`docs/recipes/pairings.md`](../docs/recipes/pairings.md)。 + +--- + +

vs Competitors

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)組み込み (CLAUDE.md)
種別メモリエンジン + MCP サーバーメモリレイヤー APIフルエージェントランタイム静的ファイル
検索 R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/A (grep)
自動キャプチャ12 hooks(手動作業ゼロ)手動の add() 呼び出しエージェントが自分で編集手動編集
検索BM25 + ベクトル + グラフ(RRF 融合)ベクトル + グラフベクトル(アーカイブ)すべてをコンテキストにロード
マルチエージェントMCP + REST + リース + シグナルAPI(調整なし)Letta ランタイム内のみエージェントごとにファイル
フレームワークロックインなし(任意の MCP クライアント)なし高(Letta 必須)エージェントごとのフォーマット
外部依存なし(SQLite + iii-engine)Qdrant / pgvectorPostgres + ベクトル DBなし
メモリライフサイクル4 層統合 + 減衰 + 自動忘却受動的抽出エージェント管理手動プルーニング
トークン効率~1,900 tokens/セッション ($10/年)統合方法によるコアメモリがコンテキスト内240 観測で 22K+ tokens
リアルタイムビューワーあり(ポート 3113)クラウドダッシュボードクラウドダッシュボードなし
セルフホストあり(デフォルト)オプションオプションあり
+ +--- + +

Quick Start

+ +互換性:このリリースは安定版の `iii-sdk` `^0.11.0` と iii-engine v0.11.x を対象とします。 + +### 30 秒で試す + +```bash +# ターミナル 1: サーバーを起動 +npx @agentmemory/agentmemory + +# ターミナル 2: サンプルデータを投入してリコールを確認 +npx @agentmemory/agentmemory demo +``` + +`demo` は 3 つの現実的なセッション(JWT 認証、N+1 クエリ修正、レート制限)を投入し、セマンティック検索を実行します。「database performance optimization」で検索すると「N+1 query fix」が見つかります — キーワード一致ではできない芸当です。 + +`http://localhost:3113` を開けばメモリがリアルタイムに構築される様子が見られます。 + +### 推奨:グローバルインストール + +`npx` はバージョン単位でキャッシュします。先週 `npx @agentmemory/agentmemory@0.9.14` を実行していた場合、素の `npx @agentmemory/agentmemory` は最新ではなく `~/.npm/_npx/` から古い 0.9.14 を提供することがあります。一度インストールすれば、素の `agentmemory` コマンドがどこでも動きます: + +```bash +npm install -g @agentmemory/agentmemory +# macOS/Linux のシステム Node で EACCES が出る場合は次を試してください: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # サーバー起動(npx 形式と同じ) +agentmemory stop # 停止 +agentmemory remove # 作成したものをすべてアンインストール +agentmemory connect claude-code # エージェントを 1 つ接続 +agentmemory doctor # 対話型診断 + 修正プロンプト +``` + +v0.9.16 以降、初回 npx 実行時にインラインでグローバルインストールを促されます — 一度 `Y` と答えれば完了です。スキップした場合、以下のいずれかで最新を取得できます: + +```bash +npx -y @agentmemory/agentmemory@latest # npm から最新を強制(クロスプラットフォーム) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # macOS/Linux のみ (POSIX shell) +``` + +Windows / PowerShell では、同等のキャッシュクリアは `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` です — 上記の `npx -y ...@latest` 形式がクロスプラットフォームの選択肢になります。 + +### セッションリプレイ + +agentmemory が記録するすべてのセッションは再生可能です。ビューワーを開き、**Replay** タブを選択し、タイムラインをスクラブしてください: プロンプト、ツール呼び出し、ツール結果、応答が個別のイベントとして表示され、再生/一時停止、速度コントロール(0.5×–4×)、キーボードショートカット(スペースで切り替え、矢印でステップ)が使えます。 + +古い Claude Code の JSONL トランスクリプトを取り込みたい? + +```bash +# デフォルトの ~/.claude/projects 配下を一括インポート +npx @agentmemory/agentmemory import-jsonl + +# あるいは単一ファイルをインポート +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +インポートしたセッションはネイティブのセッションと並んで Replay ピッカーに表示されます。内部では各エントリが `mem::replay::load`、`mem::replay::sessions`、`mem::replay::import-jsonl` の iii functions を経由します — サイドチャネルサーバーはありません。 + +### アップグレード / メンテナンス + +意図的にローカルランタイムを更新したいときは、メンテナンスコマンドを使ってください: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +警告: このコマンドは現在のワークスペース/ランタイムを変更します。JavaScript 依存を更新したり、ピン留めされた Docker イメージ `iiidev/iii:0.11.2` を pull したりすることがあります。ピン留めされていない、あるいは新しい iii エンジンをインストールすることは決してありません。 + +実装の詳細は `src/cli.ts` を参照(`src/cli.ts:544-595` 付近の `runUpgrade`)。 + +### Claude Code(1 ブロックそのまま貼り付け) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### プラグインをインストールしない Claude Code(MCP スタンドアロン) + +`/plugin install` ではなく `~/.claude.json` から直接 agentmemory の MCP サーバーを配線する場合、Claude Code は `${CLAUDE_PLUGIN_ROOT}` を解決しないため、hook スクリプトを `~/.claude/settings.json` の絶対パスに向ける必要があります。これらのパスには通常 agentmemory のバージョン(例: `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`)が埋め込まれるため、次のアップグレードで全 hook が静かに壊れます。 + +回避策: + +```bash +agentmemory connect claude-code --with-hooks +``` + +同じ hook コマンドを `~/.claude/settings.json` にマージし、現在インストールされている `@agentmemory/agentmemory` パッケージの `plugin/` ディレクトリに解決された絶対パスを書き込みます。agentmemory をアップグレードしたら、このコマンドを再実行してパスを更新してください。同じファイル内のユーザーエントリは保持され、以前の agentmemory エントリだけが置き換えられます。`/plugin install` の経路が推奨アプローチであることに変わりはありません。 + +リモートや保護されたデプロイでは、`AGENTMEMORY_URL` と `AGENTMEMORY_SECRET` を設定して Claude Code を起動します。プラグインはこの両方の値を同梱の MCP サーバーに渡します。`AGENTMEMORY_URL` が空の場合、MCP shim は `http://localhost:3111` にフォールバックします。 + +### Codex CLI(Codex プラグインプラットフォーム) + +```bash +# 1. 別ターミナルでメモリサーバーを起動 +npx @agentmemory/agentmemory + +# 2. agentmemory マーケットプレイスを登録してプラグインをインストール +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Codex プラグインは Claude Code プラグインと同じ `plugin/` ディレクトリから出荷されます。以下を登録します: + +- `@agentmemory/mcp` を MCP サーバーとして(`AGENTMEMORY_URL` が動作中の agentmemory サーバーを指す場合は 51 ツールすべてをプロキシ、サーバーに到達できない場合はローカルで 7 ツールにフォールバック) +- 6 つのライフサイクル hooks: `SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`PreCompact`、`Stop` +- 4 つの skills: `/recall`、`/remember`、`/session-history`、`/forget` + +Codex の hook エンジンは hook サブプロセスに `CLAUDE_PLUGIN_ROOT` を注入する([`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs))ので、同じ hook スクリプトが両ホストで重複なく動きます。Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure イベントは Claude Code 専用で、Codex には登録されません。 + +#### Codex Desktop: プラグイン hooks は現在無音(回避策あり) + +`CodexHooks` と `PluginHooks` はどちらも [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs) で安定版・デフォルト有効ですが、Codex Desktop ビルドは現在プラグインローカルの `hooks.json` をディスパッチしません([openai/codex#16430](https://github.com/openai/codex/issues/16430))。MCP ツールは引き続き動きますが、ライフサイクル観測だけが欠落します。 + +上流が修正を出すまでは、同じ hook コマンドをグローバルな `~/.codex/hooks.json` にミラーしてください: + +```bash +agentmemory connect codex --with-hooks +``` + +これは同梱スクリプトへの絶対パスを参照する冪等なブロックを `~/.codex/hooks.json` に追加します(ユーザースコープでは `${CLAUDE_PLUGIN_ROOT}` の展開は不要)。agentmemory をアップグレードしたら同じコマンドを再実行してパスを更新してください。同じファイル内のユーザーエントリは保持され、以前の agentmemory エントリだけが置き換えられます。 + +
+OpenClaw(このプロンプトを貼り付け) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +詳細ガイド:[`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent(このプロンプトを貼り付け) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +詳細ガイド:[`integrations/hermes/`](../integrations/hermes/) + +
+ +### その他のエージェント + +メモリサーバーを起動:`npx @agentmemory/agentmemory` + +`mcpServers` シェイプを使うホスト(Cursor、Claude Desktop、Cline、Roo Code、Windsurf、Gemini CLI、OpenClaw)では、agentmemory エントリは**同じ MCP サーバーブロック**です: + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**このエントリをホストの設定ファイルにある既存の `mcpServers` オブジェクトにマージしてください** — ファイル全体を置き換えないでください。ファイルに既に他のサーバーがある場合は、`agentmemory` をその隣にもう 1 つのキーとして追加します。`mcpServers` が完全に欠落している場合は、ブロックを `{ "mcpServers": { ... } }` の中に貼り付けてください。`${VAR}` プレースホルダーは MCP サーバー起動時にシェルから `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` を継承します — 未設定の変数は空文字列を渡し、shim は `http://localhost:3111` にフォールバックします。1 つの接続済みエントリでローカルとリモート(k8s / リバースプロキシ)両方のデプロイに対応します。 + +| エージェント | 設定ファイル | 備考 | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | `mcpServers` にマージ。ウェブサイトでワンクリックディープリンクも利用可能。 | +| **Claude Desktop** | `claude_desktop_config.json`(Application Support) | `mcpServers` にマージ。編集後 Claude Desktop を再起動。 | +| **Cline / Roo Code / Kilo Code** | Cline MCP 設定(設定 UI → MCP Servers → Edit) | 同じ `mcpServers` ブロック。 | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | 同じ `mcpServers` ブロック。 | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user`(自動マージ)。 | +| **OpenClaw** | OpenClaw MCP 設定 | 同じ `mcpServers` ブロック、または[より深いメモリプラグイン](../integrations/openclaw/)を使用。 | +| **Codex CLI(MCP のみ)** | `.codex/config.toml` | TOML シェイプ: `codex mcp add agentmemory -- npx -y @agentmemory/mcp`、または `[mcp_servers.agentmemory]` を手動で追加。 | +| **Codex CLI(フルプラグイン)** | Codex プラグインマーケットプレイス | `codex plugin marketplace add rohitg00/agentmemory` のあと `codex plugin add agentmemory@agentmemory`。MCP + 6 つのライフサイクル hooks(SessionStart、UserPromptSubmit、PreToolUse、PostToolUse、PreCompact、Stop)+ 4 つの skills を登録。Codex Desktop では、[openai/codex#16430](https://github.com/openai/codex/issues/16430) が解決するまで `agentmemory connect codex --with-hooks` も実行 — そちらではプラグイン hooks が現在無音。 | +| **OpenCode(MCP のみ)** | `opencode.json` | 異なるシェイプ — トップレベルの `mcp` キー、command は配列: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`。 | +| **OpenCode(フルプラグイン)** | `plugin/opencode/` | 22 個の自動キャプチャ hooks がセッションライフサイクル、メッセージ、ツール、エラーをカバー。2 つのスラッシュコマンド(`/recall`、`/remember`)。`plugin/opencode/` を OpenCode ワークスペースにコピーし、プラグインエントリを `opencode.json` に追加。完全な hook 表とギャップ分析は [`plugin/opencode/README.md`](../plugin/opencode/README.md) を参照。 | +| **pi** | `~/.pi/agent/extensions/agentmemory` | [`integrations/pi`](../integrations/pi/) をコピーして pi を再起動。 | +| **Hermes Agent** | `~/.hermes/config.yaml` | より深い[メモリプロバイダープラグイン](../integrations/hermes/)を使い、`memory.provider: agentmemory` を設定。 | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` が標準の `mcpServers` ブロックを書き込みます。Hook ペイロードは Claude Code とフィールド互換なので、既存の 12 hook スクリプトはそのまま動作 — 同じ `settings.json` の `hooks` セクションで配線してください。 | +| **Antigravity**(Gemini CLI の後継) | `mcp_config.json`(Antigravity の User ディレクトリ内) | `agentmemory connect antigravity` が標準の `mcpServers` ブロックを書き込みます。macOS: `~/Library/Application Support/Antigravity/User/`。Linux: `~/.config/Antigravity/User/`。2026-06-18 の Gemini CLI 終了後に使用。 | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` がユーザーレベル設定を書き込みます。ワークスペースのオーバーライドはコードの横にある `.kiro/settings/mcp.json` に。 | +| **Goose** | Goose MCP 設定 UI | 同じ `mcpServers` ブロック。 | +| **Aider** | n/a | REST API に直接話しかける: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`。 | +| **任意のエージェント(32+)** | n/a | `npx skillkit install agentmemory` がホストを自動検出してマージ。 | + +**サンドボックス化された MCP クライアント**(Flatpak / Snap / 制限的なコンテナ)はホストの `localhost` に到達できません: `env` ブロックに `"AGENTMEMORY_FORCE_PROXY": "1"` も設定し、`AGENTMEMORY_URL` をサンドボックスが実際に到達できる経路(例: LAN IP)に向けてください。 + +### プログラマティックアクセス(Python / Rust / Node) + +agentmemory はコア操作を iii functions(`mem::remember`、`mem::observe`、`mem::context`、`mem::smart-search`、`mem::forget`)として登録します。iii SDK を持つあらゆる言語が `ws://localhost:49134` で直接呼び出せます — 言語ごとに REST クライアントを用意する必要はありません。 + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +実例:[`examples/python/`](../examples/python/)(クイックスタート + 観測/リコールフロー)。iii ランタイムがないホスト向けに `:3111` の REST も引き続き利用可能。 + +### ソースから + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +`iii` が既にインストールされていれば、これでローカルの `iii-engine` で agentmemory が起動します。Docker が使える場合は Docker Compose にフォールバックします。REST、ストリーム、ビューワーはデフォルトで `127.0.0.1` にバインドします。 + +`iii-engine` を手動でインストールしてください。**agentmemory は現在 `iii-engine` を `v0.11.2` にピン留めしています** — `v0.11.6` では `iii worker add` で何でもサンドボックス化する新モデルが導入されましたが、agentmemory はまだそれ向けにリファクタリングされていません。リファクタが完了次第ピンは解除されます。サンドボックスモデルへ手動移行済みなら `AGENTMEMORY_III_VERSION=` でオーバーライドできます。 + +- **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:** `aarch64-apple-darwin` を `x86_64-apple-darwin` に置換 +- **Linux x64:** `x86_64-unknown-linux-gnu` に置換 +- **Linux arm64:** `aarch64-unknown-linux-gnu` に置換 +- **Windows:** [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2) から `iii-x86_64-pc-windows-msvc.zip` をダウンロード、`iii.exe` を展開し PATH に追加 + +または Docker を使用(同梱の `docker-compose.yml` が `iiidev/iii:0.11.2` を pull します)。詳細ドキュメント:[iii.dev/docs](https://iii.dev/docs)。 + +### Windows + +agentmemory は Windows 10/11 で動作しますが、Node.js パッケージだけでは不十分で、`iii-engine` ランタイム(別のネイティブバイナリ)もバックグラウンドプロセスとして必要です。公式の上流インストーラは `sh` スクリプトで、今のところ PowerShell インストーラや scoop/winget パッケージは存在しないため、Windows ユーザーには 2 つの経路があります: + +**選択肢 A — ビルド済み Windows バイナリ(推奨):** + +```powershell +# 1. ブラウザで https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 を開く +# (engine v0.11.6+ が要求する新しいサンドボックスモデルへ +# agentmemory がリファクタリングされるまで v0.11.2 にピン留め) +# 2. iii-x86_64-pc-windows-msvc.zip をダウンロード +# (ARM マシンの場合は iii-aarch64-pc-windows-msvc.zip) +# 3. iii.exe を PATH 上のどこかに展開、または以下に配置: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory はこの場所を自動でチェックします) +# 4. 確認: +iii --version +# 出力: 0.11.2 + +# 5. その後 agentmemory を通常通り起動: +npx -y @agentmemory/agentmemory +``` + +**選択肢 B — Docker Desktop:** + +```powershell +# 1. Docker Desktop for Windows をインストール +# 2. Docker Desktop を起動し、エンジンが動作中であることを確認 +# 3. agentmemory を実行 — 同梱の compose ファイルが自動起動します: +npx -y @agentmemory/agentmemory +``` + +**選択肢 C — スタンドアロン MCP のみ(エンジンなし):** エージェント用に MCP ツールだけが必要で、REST API、ビューワー、cron ジョブが不要なら、エンジンを完全にスキップ: + +```powershell +npx -y @agentmemory/agentmemory mcp +# あるいは shim パッケージ経由: +npx -y @agentmemory/mcp +``` + +**Windows の診断:** `npx @agentmemory/agentmemory` が失敗する場合、`--verbose` 付きで再実行して実際のエンジン stderr を確認してください。よくある失敗パターン: + +| 症状 | 修正 | +|---|---| +| `iii-engine process started` のあとに `did not become ready within 15s` | エンジンが起動時にクラッシュ — `--verbose` で再実行し stderr を確認 | +| `Could not start iii-engine` | `iii.exe` も Docker もインストールされていない。上記の選択肢 A または B を参照 | +| ポート競合 | `netstat -ano \| findstr :3111` でバインドを確認、kill するか `--port ` を使用 | +| Docker をインストール済みなのにフォールバックがスキップされる | Docker Desktop が実際に動作している(システムトレイアイコン)ことを確認 | + +> 注意: iii **エンジン** はビルド済みバイナリであり、cargo クレートではありません — `cargo install` でインストールしようとしないでください。(iii **SDK** は crates.io、npm、PyPI に公開されていますが、agentmemory には不要です。)サポートされるエンジンのインストール方法はすべて v0.11.2 にピン留めされています: 上記のビルド済み v0.11.2 バイナリ、バージョンピン**付き**の上流 `sh` インストールスクリプト `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh`(macOS/Linux)、および Docker イメージ `iiidev/iii:0.11.2`。単なる `install.sh | sh` は **最新** のエンジンをインストールしますが、agentmemory はそれをサポートしていません — 必ず `VERSION=0.11.2` を渡してください。最も簡単なのは、`npx @agentmemory/agentmemory` を実行するだけです。これがピン留めされたエンジンを `~/.agentmemory/bin` に取得してくれます。 + +--- + +

デプロイ

+ +マネージドホスト向けのワンクリックテンプレート。それぞれが +自己完結した Dockerfile を提供し、npm から +`@agentmemory/agentmemory` を pull して公式の `iiidev/iii` +Docker Hub イメージから iii engine バイナリをコピーします — 事前に +ビルドした agentmemory イメージは不要です。永続ストレージは +`/data` にマウントされます。初回起動の entrypoint は npm 同梱の +iii 設定(`127.0.0.1` をバインド)をデプロイ向けに調整した +設定(`0.0.0.0` をバインドし絶対 `/data` パスを使用)で上書きし、 +HMAC シークレットを生成、そして `gosu` で `root` から `node` +に権限を落としてから agentmemory CLI を exec します。 + +

+ Deploy to fly.io + Deploy to Railway +

+ +Render のワンクリックデプロイボタンはリポジトリルートに `render.yaml` を要求しますが、ルートをあえて綺麗に保っています。[`deploy/render/`](../deploy/render/README.md) にドキュメント化された Render Blueprint フローを使い、リポジトリ内のブループリントを手動で指してください。 + +完全なセットアップ詳細(HMAC キャプチャ、ビューワーの SSH トンネル、 +ローテーション、バックアップ、コスト下限)は +[`deploy/`](../deploy/README.md) を参照: + +- [`deploy/fly`](../deploy/fly/README.md) — 単一マシンで + `auto_stop_machines = "stop"`、アイドル時最安。 +- [`deploy/railway`](../deploy/railway/README.md) — Hobby プラン定額、 + ボリュームはダッシュボードで。 +- [`deploy/render`](../deploy/render/README.md) — Blueprint フロー、 + 有料プランで自動ディスクスナップショット。 +- [`deploy/coolify`](../deploy/coolify/README.md) — [Coolify](https://coolify.io/self-hosted) + 経由で自前 VPS にセルフホスト。同じ Docker Compose + スタックで、ホストとデータは自分の手元に。 + +公開されるのはポート `3111` のみです。`3113` のビューワーは +コンテナ内でループバックにバインドされ続けます — 各テンプレートの +README にそこへ到達する SSH トンネルパターンが記載されています。 + +--- + +

Why agentmemory

+ +すべてのコーディングエージェントはセッションが終わるとすべてを忘れます。毎セッションの最初の 5 分をスタックの再説明に浪費しています。agentmemory はバックグラウンドで動作し、それを完全になくします。 + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### 組み込みエージェントメモリとの比較 + +すべての AI コーディングエージェントには組み込みのメモリが付属します — Claude Code には `MEMORY.md`、Cursor には notepad、Cline には memory bank。これらは付箋のようなものです。agentmemory はその付箋の背後にある検索可能なデータベースです。 + +| | 組み込み (CLAUDE.md) | agentmemory | +|---|---|---| +| スケール | 200 行上限 | 無制限 | +| 検索 | すべてをコンテキストにロード | BM25 + ベクトル + グラフ(top-K のみ) | +| トークンコスト | 240 観測で 22K+ | ~1,900 tokens(92% 削減) | +| クロスエージェント | エージェントごとのファイル | MCP + REST(任意のエージェント) | +| 調整 | なし | リース、シグナル、アクション、ルーチン | +| 可観測性 | 手動でファイルを読む | ポート 3113 のリアルタイムビューワー | + +--- + +

How It Works

+ +### メモリパイプライン + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4 層メモリ統合 + +人間の脳が記憶を処理する方法に着想を得ています — 睡眠時の記憶統合と通じるものがあります。 + +| 層 | 内容 | 例え | +|------|------|---------| +| **Working(作業記憶)** | ツール使用からの生観測 | 短期記憶 | +| **Episodic(エピソード記憶)** | 圧縮されたセッション要約 | 「何が起きたか」 | +| **Semantic(意味記憶)** | 抽出された事実とパターン | 「何を知っているか」 | +| **Procedural(手続き記憶)** | ワークフローと意思決定パターン | 「どうやるか」 | + +記憶は時間とともに減衰(エビングハウス曲線)。頻繁にアクセスされる記憶は強化されます。古い記憶は自動退避。矛盾は検出され解決されます。 + +### 何をキャプチャするか + +| Hook | キャプチャ内容 | +|------|----------| +| `SessionStart` | プロジェクトパス、セッション ID | +| `UserPromptSubmit` | ユーザープロンプト(プライバシーフィルタ済み) | +| `PreToolUse` | ファイルアクセスパターン + コンテキスト富化 | +| `PostToolUse` | ツール名、入力、出力 | +| `PostToolUseFailure` | エラーコンテキスト | +| `PreCompact` | コンパクション前にメモリを再注入 | +| `SubagentStart/Stop` | サブエージェントのライフサイクル | +| `Stop` | セッション終了時の要約 | +| `SessionEnd` | セッション完了マーカー | + +### 主な機能 + +| 機能 | 説明 | +|---|---| +| **自動キャプチャ** | hooks で毎ツール使用を記録 — 手動作業ゼロ | +| **セマンティック検索** | BM25 + ベクトル + ナレッジグラフ、RRF 融合 | +| **メモリ進化** | バージョン管理、上書き、関係グラフ | +| **自動忘却** | TTL 期限切れ、矛盾検出、重要度退避 | +| **プライバシー優先** | API キー、シークレット、`` タグは保存前に除去 | +| **自己修復** | サーキットブレーカー、プロバイダーフォールバックチェーン、ヘルスモニタ | +| **Claude ブリッジ** | MEMORY.md と双方向同期 | +| **ナレッジグラフ** | エンティティ抽出 + BFS 探索 | +| **チームメモリ** | チームメンバー間で名前空間化された共有 + プライベート | +| **引用の出所追跡** | あらゆるメモリを元の観測まで遡れる | +| **Git スナップショット** | メモリ状態のバージョン、ロールバック、diff | + +--- + + + +3 つのシグナルを組み合わせるトリプルストリーム検索: + +| ストリーム | 役割 | 起動条件 | +|---|---|---| +| **BM25** | ステミング付きキーワード一致と類義語拡張 | 常時有効 | +| **Vector(ベクトル)** | 密埋め込みのコサイン類似度 | 埋め込みプロバイダー設定時 | +| **Graph(グラフ)** | エンティティ一致によるナレッジグラフ探索 | クエリにエンティティ検出時 | + +Reciprocal Rank Fusion (RRF, k=60) で融合し、セッションで多様化(セッションあたり最大 3 件)。 + +BM25 は箱から出してすぐにギリシャ文字、キリル文字、ヘブライ文字、アラビア文字、アクセント付きラテン文字をトークン化できます。中国語 / 日本語 / 韓国語のメモリには、オプションのセグメンタ(`npm install @node-rs/jieba tiny-segmenter`)をインストールして CJK 連続を単語レベルのトークンに分割してください。インストールしない場合、agentmemory は連続全体をそのままトークン化するソフトフォールバックに切り替わり、stderr に一度だけヒントを出します。 + +### 埋め込みプロバイダー + +agentmemory はプロバイダーを自動検出します。最良の結果を得るには、ローカル埋め込み(無料)をインストール: + +```bash +npm install @xenova/transformers +``` + +| プロバイダー | モデル | コスト | 備考 | +|---|---|---|---| +| **ローカル(推奨)** | `all-MiniLM-L6-v2` | 無料 | オフライン、BM25 単独より召集率 +8pp | +| Gemini | `gemini-embedding-001` | 無料枠 | 100+ 言語、768/1536/3072 次元 (MRL)、2048 トークン入力。`text-embedding-004` の後継([非推奨、2026 年 1 月 14 日に停止](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | $0.02/1M | 最高品質 | +| Voyage AI | `voyage-code-3` | 有料 | コード向け最適化 | +| Cohere | `embed-english-v3.0` | 無料試用 | 汎用 | +| OpenRouter | 任意のモデル | 場合による | マルチモデルプロキシ | + +--- + +

MCP Server

+ +53 ツール、6 リソース、3 プロンプト、4 skills — あらゆるエージェント向けで最も充実した MCP メモリツールキット。 + +> **MCP shim とフルサーバー:** 公開されている `@agentmemory/mcp` パッケージは薄い shim です。**`AGENTMEMORY_URL` 経由で動作中の agentmemory サーバーに到達できる場合に限り**、完全な 51 ツール群を公開します(プロキシモード)。サーバーに到達できない場合、shim は 7 ツールのローカルセット(`memory_save`、`memory_recall`、`memory_smart_search`、`memory_sessions`、`memory_export`、`memory_audit`、`memory_governance_delete`)にフォールバックします。`AGENTMEMORY_TOOLS=core|all` 環境変数は*サーバー側*のフラグです — shim の `env` ブロックで設定しても効果はありません。Cursor / OpenCode / Gemini CLI で 7 ツールしか見えない場合は、`npx @agentmemory/agentmemory`(または Docker スタック)を起動し、`AGENTMEMORY_URL=http://localhost:3111` を設定してください。 + +### 51 ツール + +
+コアツール(常時利用可能) + +| ツール | 説明 | +|------|-------------| +| `memory_recall` | 過去の観測を検索 | +| `memory_compress_file` | 構造を保持したまま markdown ファイルを圧縮 | +| `memory_save` | 洞察、決定、パターンを保存 | +| `memory_patterns` | 繰り返し現れるパターンを検出 | +| `memory_smart_search` | ハイブリッドなセマンティック + キーワード検索 | +| `memory_file_history` | 特定ファイルに関する過去の観測 | +| `memory_sessions` | 最近のセッション一覧 | +| `memory_timeline` | 時系列の観測 | +| `memory_profile` | プロジェクトプロファイル(概念、ファイル、パターン) | +| `memory_export` | すべてのメモリデータをエクスポート | +| `memory_relations` | 関係グラフを照会 | + +
+ +
+拡張ツール(全 51 — AGENTMEMORY_TOOLS=all を設定) + +| ツール | 説明 | +|------|-------------| +| `memory_patterns` | 繰り返し現れるパターンを検出 | +| `memory_timeline` | 時系列の観測 | +| `memory_relations` | 関係グラフを照会 | +| `memory_graph_query` | ナレッジグラフ探索 | +| `memory_consolidate` | 4 層統合を実行 | +| `memory_claude_bridge_sync` | MEMORY.md と同期 | +| `memory_team_share` | チームメンバーと共有 | +| `memory_team_feed` | 最近の共有アイテム | +| `memory_audit` | 操作の監査証跡 | +| `memory_governance_delete` | 監査証跡付き削除 | +| `memory_snapshot_create` | Git バージョンスナップショット | +| `memory_action_create` | 依存関係付き作業項目を作成 | +| `memory_action_update` | アクションのステータス更新 | +| `memory_frontier` | 優先度順のブロック解除済みアクション | +| `memory_next` | 次に最も重要なアクション 1 つ | +| `memory_lease` | 排他的アクションリース(マルチエージェント) | +| `memory_routine_run` | ワークフロー ルーチンをインスタンス化 | +| `memory_signal_send` | エージェント間メッセージング | +| `memory_signal_read` | 受領確認付きでメッセージを読む | +| `memory_checkpoint` | 外部条件ゲート | +| `memory_mesh_sync` | インスタンス間 P2P 同期 | +| `memory_sentinel_create` | イベント駆動ウォッチャー | +| `memory_sentinel_trigger` | 外部からセンチネルを発火 | +| `memory_sketch_create` | 一時的なアクショングラフ | +| `memory_sketch_promote` | 永続化に昇格 | +| `memory_crystallize` | アクションチェーンをコンパクト化 | +| `memory_diagnose` | ヘルスチェック | +| `memory_heal` | 詰まった状態を自動修復 | +| `memory_facet_tag` | 次元:値タグ | +| `memory_facet_query` | facet タグで照会 | +| `memory_verify` | 出所を追跡 | + +
+ +### 6 リソース · 3 プロンプト · 4 Skills + +| 種類 | 名前 | 説明 | +|------|------|-------------| +| Resource | `agentmemory://status` | ヘルス、セッション数、メモリ数 | +| Resource | `agentmemory://project/{name}/profile` | プロジェクト別インテリジェンス | +| Resource | `agentmemory://memories/latest` | 直近 10 件のアクティブメモリ | +| Resource | `agentmemory://graph/stats` | ナレッジグラフ統計 | +| Prompt | `recall_context` | 検索してコンテキストメッセージを返す | +| Prompt | `session_handoff` | エージェント間でのハンドオフデータ | +| Prompt | `detect_patterns` | 繰り返し現れるパターンを分析 | +| Skill | `/recall` | メモリを検索 | +| Skill | `/remember` | 長期メモリに保存 | +| Skill | `/session-history` | 最近のセッション要約 | +| Skill | `/forget` | 観測/セッションを削除 | + +### スタンドアロン MCP + +フルサーバーなしで実行 — 任意の MCP クライアント向け。以下のどちらも動きます: + +```bash +npx -y @agentmemory/agentmemory mcp # 正規(常時利用可能) +npx -y @agentmemory/mcp # shim パッケージのエイリアス +``` + +またはエージェントの MCP 設定に追加: + +ほとんどのエージェント(Cursor、Claude Desktop、Cline、Roo Code、Windsurf、Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +`agentmemory` エントリはホストの既存 `mcpServers` オブジェクトにマージし、ファイル全体を置き換えないでください。ホストの `localhost` に到達できないサンドボックスクライアントには、env ブロックに `"AGENTMEMORY_FORCE_PROXY": "1"` を追加し、`AGENTMEMORY_URL` をサンドボックスが到達できる経路に設定してください。 + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +リポジトリからプラグインファイルをコピー: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Real-Time Viewer

+ +ポート `3113` で自動起動。ライブ観測ストリーム、セッションエクスプローラ、メモリブラウザ、ナレッジグラフの可視化、ヘルスダッシュボード。 + +```bash +open http://localhost:3113 +``` + +ビューワーサーバーはデフォルトで `127.0.0.1` にバインドします。REST 経由の `/agentmemory/viewer` エンドポイントは通常の `AGENTMEMORY_SECRET` ベアラートークン規則に従います。CSP ヘッダーはレスポンスごとのスクリプト nonce を使い、インラインハンドラ属性は無効化(`script-src-attr 'none'`)。 + +--- + +

iii Console

+ +`:3113` のビューワーはエージェントが**覚えた**ことを見せます。[iii コンソール](https://iii.dev/docs/console)はエージェントが**やった**ことを見せます — 各メモリ操作は OpenTelemetry トレース、各 KV エントリは編集可能、各 function は呼び出し可能、各ストリームは tap 可能。同じメモリへの 2 つの窓: 一方はプロダクト形、もう一方はエンジン形。 + +`memory_smart_search` の発火を眺め、BM25 スキャン → 埋め込み参照 → RRF 融合 → リランカーをウォーターフォールで見ます。KV ブラウザで詰まった統合タイマーを編集します。調整したペイロードで `PostToolUse` hook を再生します。WebSocket ストリームをピンして観測がライブで着地するのを眺めます。 + +agentmemory はこれを無料で提供します。すべての function、トリガー、ステートスコープ、ストリームが iii プリミティブだからです — カスタム実装も計装も必要ありません。 + +

+ iii console Workers page — connected workers including agentmemory instances with live function counts and runtime metadata +
+ Workers ページ: agentmemory 自身を含む、接続中のすべての worker と PID、function 数、ランタイム、最終応答時刻。 +

+ +**インストール済みです。** コンソールは `iii` に同梱 — 別途インストーラはありません。 + +**agentmemory と並行して起動:** + +```bash +# agentmemory ビューワーがポート 3113 を握っているので、コンソールは 3114 で実行します。 +# エンジン REST (3111)、WebSocket (3112)、bridge (49134) のデフォルトは agentmemory と一致します。 +iii console --port 3114 +``` + +その後 `http://localhost:3114` を開きます。`--enable-flow` で実験的なアーキテクチャグラフページを有効化します。 + +エンジンエンドポイントを移動した場合のみ上書き: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**コンソールでできること:** + +| ページ | 用途 | +|------|-----------| +| **Workers** | agentmemory worker 自身を含む、接続中の各 worker とライブメトリクスを表示。 | +| **Functions** | JSON ペイロードを与えて agentmemory の任意の function を直接呼び出し — クライアントを配線せずに `memory.recall`、`memory.consolidate`、`graph.query` をテストできて便利。 | +| **Triggers** | HTTP、cron、イベント、ステートのトリガーを再生 — 統合 cron を手動で発火、HTTP ルートを再試行、ステート変更を発行。 | +| **States** | フル CRUD の KV ブラウザ — セッション、メモリスロット、ライフサイクルタイマー、埋め込みインデックス — その場で値を編集。 | +| **Streams** | メモリ書き込み、hook イベント、観測更新が iii ストリームを流れる様子をライブで監視する WebSocket モニタ。 | +| **Queues** | 永続キューのトピック + デッドレター管理。失敗した埋め込み / 圧縮ジョブを再生または破棄。 | +| **Traces** | OpenTelemetry のウォーターフォール / フレーム / サービスブレークダウン。`trace_id` でフィルタすれば、単一の `memory.search` がどの function、DB 呼び出し、埋め込みリクエストを生んだか正確にわかります。 | +| **Logs** | 構造化 OTEL ログをフィルタし、trace/span ID と相関付け。 | +| **Config** | ランタイム設定 — エンジンがどの worker、プロバイダー、ポートで動いているか確認。 | +| **Flow** | (オプション、`--enable-flow`)各 worker、トリガー、ストリームの対話型アーキテクチャグラフ。 | + +

+ iii console trace waterfall view showing per-span duration +
+ Traces: すべてのメモリ操作についてウォーターフォール / フレーム / サービスブレークダウン。 +

+ +**Traces は既にオン:** + +`iii-config.yaml` は出荷時から `iii-observability` worker を有効化(`exporter: memory`、`sampling_ratio: 1.0`、メトリクス + ログ)。追加設定不要 — agentmemory が起動した瞬間に、すべてのメモリ操作がトレーススパンとコンソールが読み取れる構造化ログを出します。 + +代わりに Jaeger / Honeycomb / Grafana Tempo へエクスポートしたい場合は、`exporter: memory` を `exporter: otlp` に変更し、iii の可観測性ドキュメントに従ってコレクタエンドポイントを設定してください。 + +> **注意:** コンソール自身に認証は強制されていません — デフォルトの `127.0.0.1` バインドのままにし、決して公開しないでください。 + +--- + +

Powered by iii

+ +agentmemory は**それ自体が稼働中の [iii](https://iii.dev) インスタンス**です。function、トリガー、KV ステート、ストリーム、OTEL トレース — すべてが iii プリミティブです。Postgres、Redis、Express、pm2、Prometheus をインストールしなかったのは、iii がそれらを置き換えるからです。 + +つまり、もう 1 つのコマンドで agentmemory にまったく新しい機能を拡張できます。 + +### 1 つのコマンドで agentmemory を拡張 + +```bash +iii worker add iii-pubsub # メモリ書き込みを接続中のすべてのインスタンスに fan-out +iii worker add iii-cron # スケジュール統合、減衰スイープ、スナップショットローテーション +iii worker add iii-queue # 埋め込み + 圧縮ジョブの永続リトライ +iii worker add iii-observability # すべてのメモリ操作に OTEL トレース(デフォルト オン) +iii worker add iii-sandbox # リコールしたコードを隔離 microVM 内で実行 +iii worker add iii-database # SQL バックエンドのステートアダプタに切り替え +iii worker add mcp # agentmemory MCP の横に汎用 MCP ホストを立てる +``` + +各 `iii worker add` は新しい function とトリガーを、agentmemory が既に動いているのと同じエンジンに登録します。ビューワーとコンソールがすぐに拾い上げます — リロード不要、新しい統合不要、新しいコンテナ不要。 + +| `iii worker add` | agentmemory の上に得られるもの | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | マルチインスタンスメモリ: すべての `remember` が fan-out、すべての `search` が和集合を読む | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | スケジュールされたライフサイクル — 夜間統合、週次スナップショット、固定クロックでの減衰 | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | 永続リトライ: 失敗した埋め込み + 圧縮ジョブが再起動を生き延び、観測は失われない | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | すべての function に OTEL トレース、メトリクス、ログ — 初日から `iii-config.yaml` に配線済み | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | `memory_recall` から出てきたコードはあなたのシェルではなく使い捨て VM 内で実行 | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | デフォルトのインメモリ KV では足りないときの SQL バックエンドのステートアダプタ | +| [`mcp`](https://workers.iii.dev/workers/mcp) | agentmemory の隣に追加の MCP サーバーを立て、同じエンジンを共有 | + +完全なレジストリ:[workers.iii.dev](https://workers.iii.dev)。そこにあるすべての worker は agentmemory が使っているのと同じプリミティブで組み立てられています — そして既に手元にある agentmemory もその 1 つです。 + +### iii が置き換えるもの + +| 従来のスタック | agentmemory が使うもの | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + インメモリベクトルインデックス | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | iii engine worker 監視 | +| Prometheus / Grafana | iii OTEL + ヘルスモニタ | +| カスタムプラグインシステム | `iii worker add ` | + +**118 ソースファイル · ~21,800 LOC · 950+ テスト · 123 functions · 34 KV スコープ** — すべて 3 つのプリミティブの上に。`agentmemory plugin install` はありません。プラグインシステムは iii そのものです。 + +--- + +

Configuration

+ +### LLM プロバイダー + +agentmemory は環境から自動検出します。デフォルトでは、プロバイダーを設定するか Claude 購読フォールバックに明示的にオプトインしない限り、LLM 呼び出しは行いません。 + +| プロバイダー | 設定 | 備考 | +|----------|--------|-------| +| **No-op(デフォルト)** | 設定不要 | LLM 駆動の compress/summarize は無効。合成 BM25 圧縮 + リコールは引き続き動作。以前 Claude 購読フォールバックに依存していた場合は、下記の `AGENTMEMORY_ALLOW_AGENT_SDK` を参照。 | +| Anthropic API | `ANTHROPIC_API_KEY` | トークン単位課金 | +| MiniMax | `MINIMAX_API_KEY` | Anthropic 互換 | +| Gemini | `GEMINI_API_KEY` | 埋め込みも有効化 | +| OpenRouter | `OPENROUTER_API_KEY` | 任意のモデル | +| Claude 購読フォールバック | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | オプトインのみ。`@anthropic-ai/claude-agent-sdk` セッションを生成 — 過去に無限の Stop-hook 再帰を引き起こしたため、もはやデフォルトではありません。 | + +### コストを意識したモデル選択 + +バックグラウンド圧縮は観測のたびに走るため、モデル選択は月額支出に大きく効きます。記録されたワークロード: 635 リクエスト / 888K トークン / 35 時間のアクティブ使用、2026-05-23 時点の OpenRouter 価格で 3 モデルを比較。 + +| 階層 | モデル | 入力 / 1M | 出力 / 1M | 35 時間のワークロードでのコスト | 備考 | +|------|-------|------------|-------------|---------------------------|-------| +| 推奨 | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | 圧縮 + 要約品質が手堅く、Sonnet の約 10 分の 1 のコスト。 | +| 推奨 | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | やや古めだが圧縮のみのワークロードには十分。 | +| 推奨 | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | セッションがコード中心ならコード推論が強い。 | +| プレミアム | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | 品質は高いが常時稼働のバックグラウンドには高価。 | +| プレミアム | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | Sonnet と同階層。 | +| 回避 | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | 推論クラスのモデル。圧縮には大幅な過剰支出。 | + +agentmemory は `OPENROUTER_MODEL` がプレミアム階層パターンと一致するときランタイム警告を表示します。納得して選んだあとは `AGENTMEMORY_SUPPRESS_COST_WARNING=1` で消音できます。 + +メモリ作業における品質対コストのトレードオフ: 圧縮は品質のハードルが比較的緩い要約タスクです(要約を読み返すのはエージェントであってユーザーではありません)。DeepSeek-V4-Pro / Qwen3-Coder はこのタスクで Sonnet と誤差範囲に収まる一方、コストは約 10 分の 1 です。プレミアム階層のモデルは、あなたが直接読むクエリに取っておきましょう。 + +出典:[OpenRouter の Sonnet 4.6 価格](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing)、[DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro)、[DeepSeek の価格に関する注](https://api-docs.deepseek.com/quick_start/pricing/)。 + +### マルチエージェントメモリ(`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +複数のロール(architect / developer / reviewer / researcher / support-agent)が 1 つの agentmemory サーバーを共有するマルチエージェント構成では、`AGENT_ID` がすべての書き込みに発信したロールのタグを付けます。`AGENTMEMORY_AGENT_SCOPE` はリコールがそのタグでフィルタするかどうかを制御します。 + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # 任意、デフォルトは "shared" +``` + +2 つのモード: + +| モード | 書き込みにタグ | リコールでフィルタ | 使いどころ | +|------|------------|---------------|-------------| +| `shared`(デフォルト) | はい | いいえ | 監査証跡付きのクロスエージェントコンテキスト。Architect は developer のメモを見られるが、各行に発言者が記録されます。 | +| `isolated` | はい | はい | 厳格分離。Architect は developer の観測 / メモリ / セッションを決して見られません。 | + +`AGENT_ID` が設定されたときにタグ付けされるもの:`Session.agentId`、`RawObservation.agentId`、`CompressedObservation.agentId`、`Memory.agentId`。ロールは `api::session::start` → `mem::observe` → `mem::compress` → KV を流れます。 + +isolated モードでフィルタされるもの:`mem::smart-search`、`/agentmemory/memories`、`/agentmemory/observations`、`/agentmemory/sessions`。各エンドポイントはリクエスト単位でオーバーライドする `?agentId=` を受け付け、`?agentId=*` で環境スコープから完全にオプトアウトできます。`/memories` はさらに `?includeOrphans=true` を受け付け、`agentId` が undefined の AGENT_ID 導入前のメモリを浮上させます。 + +SDK / REST 層での呼び出し単位オーバーライド: すべての変更系エンドポイント(`/session/start`、`/remember`)はリクエストボディに `agentId` フィールドを受け付け、環境変数より優先されます。1 つのサーバープロセス経由で多数のロールをルーティングするランタイムに便利です。 + +`AGENT_ID` が未設定の場合、メモリはスコープなしのまま(従来の挙動、タグなし・フィルタなし)。 + +### ポート + +agentmemory + iii-engine はデフォルトで 4 つのポートをバインドします。再起動が `port in use` で失敗する場合、この表でどのプロセスを探せばよいか分かります。 + +| ポート | プロセス | 用途 | 環境変数で上書き | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | 内部ストリーム worker(agentmemory + ビューワーが消費) | `III_STREAMS_PORT` | +| `3113` | agentmemory | リアルタイムビューワー(`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — worker はここに登録、OTel テレメトリもここを流れる | `III_ENGINE_URL`(完全 URL、デフォルト `ws://localhost:49134`) | + +クラッシュ後にポートが解放されないときの古いプロセス整理: + +```bash +# macOS / Linux — 各ポートで動いているものを探して kill +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` は正常終了時に worker と engine の pidfile を綺麗に回収します。上の手動クリーンアップは、どちらの pidfile も残っていないクラッシュ後の状態を対象とします。 + +### 設定ファイル + +agentmemory のランタイム設定は、各シェルで変数を export するのではなく `~/.agentmemory/.env` に置いてください。ビューワーが `export ANTHROPIC_API_KEY=...` のようなセットアップヒントを表示したら、これをこのファイルに `ANTHROPIC_API_KEY=...` として(`export` プレフィックスなしで)コピーしてから agentmemory を再起動してください。 + +プロセスの環境変数も引き続き有効で、ファイルの値より優先されます。 + +Windows では同じファイルが `%USERPROFILE%\.agentmemory\.env` にあります: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +API キーの代わりに Claude Code Pro/Max 購読でテストするには、明示的にオプトイン: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +グラフや統合の機能を使いたい場合は同じファイルでオン: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### 環境変数 + +`~/.agentmemory/.env` を作成: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +ポート `3111` 上の 124 エンドポイント。REST API はデフォルトで `127.0.0.1` にバインドします。`AGENTMEMORY_SECRET` が設定されている場合、保護されたエンドポイントは `Authorization: Bearer ` を要求し、mesh sync エンドポイントは両ピアで `AGENTMEMORY_SECRET` を要求します。 + +
+主要エンドポイント + +| メソッド | パス | 説明 | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | ヘルスチェック(常に公開) | +| `POST` | `/agentmemory/session/start` | セッション開始 + コンテキスト取得 | +| `POST` | `/agentmemory/session/end` | セッション終了 | +| `POST` | `/agentmemory/observe` | 観測キャプチャ | +| `POST` | `/agentmemory/smart-search` | ハイブリッド検索 | +| `POST` | `/agentmemory/context` | コンテキスト生成 | +| `POST` | `/agentmemory/remember` | 長期メモリに保存 | +| `POST` | `/agentmemory/forget` | 観測の削除 | +| `POST` | `/agentmemory/enrich` | ファイルコンテキスト + メモリ + bug | +| `GET` | `/agentmemory/profile` | プロジェクトプロファイル | +| `GET` | `/agentmemory/export` | 全データをエクスポート | +| `POST` | `/agentmemory/import` | JSON からインポート | +| `POST` | `/agentmemory/graph/query` | ナレッジグラフ照会 | +| `POST` | `/agentmemory/team/share` | チームと共有 | +| `GET` | `/agentmemory/audit` | 監査証跡 | + +完全なエンドポイント一覧:[`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Development

+ +```bash +npm run dev # ホットリロード +npm run build # 本番ビルド +npm test # 950+ テスト +npm run test:integration # API テスト(サービス起動が必要) +``` + +**前提:** Node.js >= 20、[iii-engine](https://iii.dev/docs) または Docker + +

License

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.ko-KR.md b/READMEs/README.ko-KR.md new file mode 100644 index 0000000..369797c --- /dev/null +++ b/READMEs/README.ko-KR.md @@ -0,0 +1,1360 @@ +

+ agentmemory — AI 코딩 에이전트를 위한 영구 메모리 +

+ +

+ + 코딩 에이전트가 모든 것을 기억합니다. 더 이상 다시 설명할 필요가 없습니다. + Built on iii engine +
+ Claude Code, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode 및 모든 MCP 클라이언트를 위한 영구 메모리입니다. +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ 설계 문서: gist 기준 1200 stars / 172 forks +

+ +

+ 이 gist는 Karpathy의 LLM Wiki 패턴을 신뢰도 점수, 라이프사이클, 지식 그래프, 하이브리드 검색으로 확장한 것입니다. agentmemory는 그 구현체입니다. +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ agentmemory 데모 +

+ +

+ 설치 • + 빠른 시작 • + 벤치마크 • + 경쟁 제품 비교 • + 에이전트 • + 동작 방식 • + MCP • + 뷰어 • + iii Console • + Powered by iii • + 설정 • + API +

+ +--- + +## Install + +```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 connect claude-code # wire your agent (also: codex, cursor, gemini-cli, ...) +``` + +또는 `npx`로 설치 없이 실행: + +```bash +npx @agentmemory/agentmemory +``` + +참고 — npx는 버전별로 캐싱합니다. 단순한 `npx @agentmemory/agentmemory`가 이전 릴리스를 제공한다면, `npx -y @agentmemory/agentmemory@latest`로 최신 버전을 강제로 가져오거나 `rm -rf ~/.npm/_npx`로 캐시를 한 번 비우십시오(macOS/Linux. Windows에서는 `%LOCALAPPDATA%\npm-cache\_npx`를 삭제). v0.9.16부터의 첫 npx 실행은 전역 설치 여부를 인라인으로 묻기 때문에, 이후에는 어디서나 단순한 `agentmemory` 명령이 동작합니다. + +전체 옵션은 아래 [빠른 시작](#quick-start)을 참고하십시오. 에이전트별 연결 방법은 [모든 에이전트와 호환](#works-with-every-agent) 섹션에서 확인할 수 있습니다. + +--- + +

모든 에이전트와 호환

+ +agentmemory는 hooks, MCP, REST API를 지원하는 모든 에이전트와 호환됩니다. 모든 에이전트는 동일한 메모리 서버를 공유합니다. + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+native plugin + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+native plugin + 6 hooks + MCP +
+OpenClaw
+OpenClaw
+native plugin + MCP +
+Hermes
+Hermes
+native plugin + MCP +
+pi
+pi
+native plugin + MCP +
+OpenHuman
+OpenHuman
+native Memory trait backend +
+Cursor
+Cursor
+MCP server +
+Gemini CLI
+Gemini CLI
+MCP server +
+OpenCode
+OpenCode
+22 hooks + MCP + plugin +
+Cline
+Cline
+MCP server +
+Goose
+Goose
+MCP server +
+Kilo Code
+Kilo Code
+MCP server +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP server +
+Windsurf
+Windsurf
+MCP server +
+Roo Code
+Roo Code
+MCP server +
+ +

+ MCP 또는 HTTP를 지원하는 모든 에이전트와 호환됩니다. 서버 하나, 모든 에이전트가 메모리를 공유합니다. +

+ +--- + +세션마다 같은 아키텍처를 설명하고, 같은 버그를 다시 찾고, 같은 선호 사항을 다시 가르치게 됩니다. 내장 메모리(CLAUDE.md, .cursorrules)는 200줄 한도에서 멈추고 금세 낡습니다. agentmemory가 이 문제를 해결합니다. 에이전트의 동작을 조용히 캡처하여 검색 가능한 메모리로 압축하고, 다음 세션이 시작될 때 적절한 컨텍스트를 주입합니다. 명령 하나면 됩니다. 모든 에이전트에서 동작합니다. + +**무엇이 바뀌는가:** 세션 1에서 JWT 인증을 설정합니다. 세션 2에서 rate limiting을 요청합니다. 에이전트는 이미 인증이 `src/middleware/auth.ts`의 jose 미들웨어로 처리된다는 것, 테스트가 토큰 검증을 다룬다는 것, 그리고 Edge 호환성 때문에 jsonwebtoken 대신 jose를 선택했다는 것을 알고 있습니다. 다시 설명할 필요도, 복사·붙여넣기도 필요 없습니다. 에이전트가 그냥 *알고* 있습니다. + +```bash +npx @agentmemory/agentmemory +``` + +> **v0.9.0의 새로운 기능** — [agent-memory.dev](https://agent-memory.dev)의 랜딩 사이트, 파일시스템 커넥터(`@agentmemory/fs-watcher`), 독립형 MCP가 이제 실행 중인 서버로 프록시되어 hooks와 뷰어가 일치합니다. 모든 삭제 경로에 감사 정책이 코드로 명문화되었고, 작은 Node 프로세스에서 health가 `memory_critical`로 잘못 표시되지 않습니다. 전체 노트는 [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18)에서 확인할 수 있습니다. + +--- + +

벤치마크

+ + + + + + +
+ +### 검색 정확도 + +**coding-agent-life-v1** (자체 코퍼스, 샌드박스 재현 가능) + +| 어댑터 | P@5 | R@5 | Top-5 적중률 | p50 지연 | +|---|---|---|---|---| +| **agentmemory hybrid** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep baseline | 0.267 | 0.967 | 15 / 15 | 0 ms | + +Top-5 적중률 100%. 동일한 입력에서 grep 기준선 대비 정밀도가 **2.2배** 더 높습니다. 유형별 전체 분석은 다음에서 확인할 수 있습니다: [`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개 질문) + +| 시스템 | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| BM25-only fallback | 86.2% | 94.6% | 71.5% | + + + +### 토큰 절감 + +| 방식 | 연간 토큰 | 연간 비용 | +|---|---|---| +| 전체 컨텍스트를 매번 붙여넣기 | 19.5M+ | 불가능(컨텍스트 윈도우 초과) | +| LLM 요약 | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + 로컬 임베딩 | ~170K | **$0** | + +
+ +> 임베딩 모델: `all-MiniLM-L6-v2` (로컬, 무료, API 키 불필요). 전체 보고서: [`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](../benchmark/QUALITY.md), [`benchmark/SCALE.md`](../benchmark/SCALE.md). 경쟁 제품 비교: [`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory 대 mem0, Letta, Khoj, claude-mem, Hippo. + +**로컬 재현 방법:** [`eval/README.md`](../eval/README.md) — LongMemEval `_s`(공개 500-Q)와 `coding-agent-life-v1`(자체 15-세션 코퍼스)을 위한 어댑터 플러그형 하니스. grep / vector / agentmemory 어댑터를 나란히 평가하고, NDJSON으로 출력하며, 게시된 스코어카드는 [`docs/benchmarks/`](../docs/benchmarks/)에 보관됩니다. + +**다음과 함께 사용하기 좋습니다: [codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything), [Graphify](https://github.com/safishamsi/graphify).** 코드 그래프 인덱싱, 멀티 에이전트 빌드 파이프라인, 그리고 docs/PDF/이미지/비디오에 걸친 더 넓은 지식 그래프. agentmemory는 작업을 기억하고, 이 세 프로젝트는 나머지 컨텍스트 레이어를 밝혀줍니다. 레시피와 질문 라우팅 표: [`docs/recipes/pairings.md`](../docs/recipes/pairings.md). + +--- + +

경쟁 제품 비교

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)내장 메모리 (CLAUDE.md)
유형메모리 엔진 + MCP 서버메모리 레이어 API완전한 에이전트 런타임정적 파일
검색 R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)해당 없음 (grep)
자동 캡처12 hooks (수동 작업 없음)수동 add() 호출에이전트 자체 편집수동 편집
검색BM25 + Vector + Graph (RRF 융합)Vector + GraphVector (archival)모든 것을 컨텍스트에 로드
멀티 에이전트MCP + REST + leases + signalsAPI (조정 없음)Letta 런타임 내에서만에이전트별 파일
프레임워크 종속성없음 (모든 MCP 클라이언트)없음높음 (Letta 사용 필수)에이전트별 포맷
외부 의존성없음 (SQLite + iii-engine)Qdrant / pgvectorPostgres + 벡터 DB없음
메모리 라이프사이클4-tier 통합 + 감쇠 + 자동 망각수동적 추출에이전트 관리수동 정리
토큰 효율세션당 ~1,900 토큰 ($10/년)통합 방식에 따라 다름핵심 메모리는 컨텍스트에 상주관측 240개 기준 22K+ 토큰
실시간 뷰어있음 (port 3113)클라우드 대시보드클라우드 대시보드없음
셀프 호스팅예 (기본)선택 사항선택 사항
+ +--- + +

빠른 시작

+ +호환성: 이 릴리스는 안정 버전 `iii-sdk` `^0.11.0`과 iii-engine v0.11.x를 대상으로 합니다. + +### 30초 만에 사용해 보기 + +```bash +# Terminal 1: start the server +npx @agentmemory/agentmemory + +# Terminal 2: seed sample data and see recall in action +npx @agentmemory/agentmemory demo +``` + +`demo`는 현실적인 세션 3개(JWT 인증, N+1 쿼리 수정, rate limiting)를 시드하고, 그 위에서 시맨틱 검색을 실행합니다. "database performance optimization"으로 검색하면 "N+1 query fix"를 찾는 것을 확인할 수 있는데, 키워드 매칭으로는 불가능한 결과입니다. + +`http://localhost:3113`을 열어서 메모리가 실시간으로 쌓이는 것을 지켜보십시오. + +### 권장: 전역 설치 + +`npx`는 버전별로 캐싱합니다. 지난주에 `npx @agentmemory/agentmemory@0.9.14`를 실행했다면, 단순한 `npx @agentmemory/agentmemory`는 최신 릴리스가 아니라 `~/.npm/_npx/`에 캐시된 0.9.14를 제공할 수 있습니다. 한 번 설치하면 단순한 `agentmemory` 명령이 어디서나 동작합니다: + +```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 stop # tear it down +agentmemory remove # uninstall everything we created +agentmemory connect claude-code # wire one agent +agentmemory doctor # interactive diagnostics + fix prompts +``` + +v0.9.16 이후부터 첫 npx 실행은 인라인으로 전역 설치 여부를 묻습니다 — `Y`로 한 번만 답하면 설정이 끝납니다. 만약 건너뛰었다면, 새로 가져오기 위해 다음 중 하나를 사용하십시오: + +```bash +npx -y @agentmemory/agentmemory@latest # forces latest from npm (cross-platform) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # macOS/Linux only (POSIX shell) +``` + +Windows / PowerShell에서 동일한 캐시 비우기는 `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"`입니다 — 위의 `npx -y ...@latest`가 크로스 플랫폼 옵션입니다. + +### 세션 리플레이 + +agentmemory가 기록한 모든 세션은 재생 가능합니다. 뷰어를 열어 **Replay** 탭을 선택하고 타임라인을 스크럽하면 프롬프트, 도구 호출, 도구 결과, 응답이 별개의 이벤트로 렌더링됩니다. 재생/일시정지, 속도 제어(0.5×–4×), 키보드 단축키(space로 토글, 화살표로 단계 이동)를 모두 지원합니다. + +가져오고 싶은 기존 Claude Code JSONL 트랜스크립트가 있습니까? + +```bash +# Import everything under the default ~/.claude/projects +npx @agentmemory/agentmemory import-jsonl + +# Or import a single file +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +가져온 세션은 네이티브 세션과 함께 Replay 선택기에 표시됩니다. 내부적으로 각 항목은 `mem::replay::load`, `mem::replay::sessions`, `mem::replay::import-jsonl` iii 함수로 라우팅됩니다 — 별도의 사이드 채널 서버 없이. + +### 업그레이드 / 유지보수 + +로컬 런타임을 의도적으로 업데이트할 때는 maintenance 명령을 사용하십시오: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +경고: 이 명령은 현재 workspace/런타임을 변경합니다. JavaScript 의존성을 업데이트할 수 있으며, 고정된 Docker 이미지 `iiidev/iii:0.11.2`를 pull할 수 있습니다. 고정되지 않았거나 더 새로운 iii 엔진을 설치하는 일은 절대 없습니다. + +구현 세부 사항은 `src/cli.ts`에 있습니다 (`runUpgrade`는 `src/cli.ts:544-595` 부근 참고). + +### Claude Code (블록 한 번, 붙여넣기) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### 플러그인 설치 없이 Claude Code 사용 (MCP-독립형 경로) + +`/plugin install` 대신 `~/.claude.json`을 통해 agentmemory의 MCP 서버를 직접 연결한 경우, Claude Code는 `${CLAUDE_PLUGIN_ROOT}`를 해석하지 못하므로 `~/.claude/settings.json`의 hook 스크립트를 절대 경로로 지정해야 합니다. 이 경로들은 일반적으로 agentmemory 버전을 포함하기 때문에 (예: `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), 다음 업그레이드에서 모든 hook이 조용히 깨질 수 있습니다. + +해결책: + +```bash +agentmemory connect claude-code --with-hooks +``` + +이 명령은 현재 설치된 `@agentmemory/agentmemory` 패키지의 번들된 `plugin/` 디렉터리로 해석된 절대 경로로 동일한 hook 명령을 `~/.claude/settings.json`에 병합합니다. agentmemory를 업그레이드한 후 동일한 명령을 다시 실행하여 경로를 갱신하십시오. 동일한 파일의 사용자 항목은 보존되며, 이전 agentmemory 항목만 교체됩니다. `/plugin install` 경로를 사용하는 것이 여전히 권장 방식입니다. +원격 또는 보호된 배포의 경우, `AGENTMEMORY_URL`과 `AGENTMEMORY_SECRET`을 설정한 채로 Claude Code를 실행하십시오. 플러그인은 두 값을 모두 번들된 MCP 서버로 전달합니다. `AGENTMEMORY_URL`이 비어 있을 때는 MCP shim이 `http://localhost:3111`을 사용합니다. + +### Codex CLI (Codex 플러그인 플랫폼) + +```bash +# 1. start the memory server in a separate terminal +npx @agentmemory/agentmemory + +# 2. register the agentmemory marketplace and install the plugin +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Codex 플러그인은 Claude Code 플러그인과 동일한 `plugin/` 디렉터리에서 제공됩니다. 다음을 등록합니다: + +- `@agentmemory/mcp`를 MCP 서버로 등록 (`AGENTMEMORY_URL`이 실행 중인 agentmemory 서버를 가리킬 때 51개 도구 모두 프록시. 도달 가능한 서버가 없으면 로컬에서 7개 도구로 폴백) +- 6개 라이프사이클 hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 4개 skills: `/recall`, `/remember`, `/session-history`, `/forget` + +Codex의 hook 엔진은 hook 서브프로세스에 `CLAUDE_PLUGIN_ROOT`를 주입하므로 ([`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs) 참고), 동일한 hook 스크립트가 중복 없이 두 호스트에서 모두 동작합니다. Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure 이벤트는 Claude Code 전용이며 Codex에는 등록되지 않습니다. + +#### Codex Desktop: 플러그인 hooks가 현재 동작하지 않음 (해결책 있음) + +`CodexHooks`와 `PluginHooks`는 [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs)에서 모두 안정 + 기본 활성화 상태이지만, 현재 Codex Desktop 빌드는 플러그인-로컬 `hooks.json`을 디스패치하지 않습니다 ([openai/codex#16430](https://github.com/openai/codex/issues/16430)). MCP 도구는 여전히 동작합니다. 라이프사이클 관측만 누락됩니다. + +업스트림 수정이 적용될 때까지 동일한 hook 명령을 전역 `~/.codex/hooks.json`에 미러링하십시오: + +```bash +agentmemory connect codex --with-hooks +``` + +이 명령은 번들된 스크립트의 절대 경로를 참조하는 idempotent 블록을 `~/.codex/hooks.json`에 추가합니다(사용자 스코프에서 `${CLAUDE_PLUGIN_ROOT}` 확장이 필요 없음). agentmemory를 업그레이드한 후 동일한 명령을 다시 실행하여 경로를 갱신하십시오. 동일한 파일의 사용자 항목은 보존되며, 이전 agentmemory 항목만 교체됩니다. + +
+OpenClaw (이 프롬프트를 붙여넣으세요) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +전체 가이드: [`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent (이 프롬프트를 붙여넣으세요) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +전체 가이드: [`integrations/hermes/`](../integrations/hermes/) + +
+ +### 다른 에이전트 + +메모리 서버 시작: `npx @agentmemory/agentmemory` + +agentmemory 항목은 `mcpServers` 형태를 사용하는 모든 호스트(Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw)에서 **동일한 MCP 서버 블록**입니다: + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**호스트 설정 파일의 기존 `mcpServers` 객체에 이 항목을 병합하십시오** — 파일 전체를 교체하지 마십시오. 파일에 이미 다른 서버가 있다면, `agentmemory`를 `mcpServers` 안의 또 다른 키로 옆에 추가하십시오. `mcpServers` 자체가 없다면 `{ "mcpServers": { ... } }` 안에 블록을 붙여넣으십시오. `${VAR}` 자리표시자는 MCP 서버 실행 시 셸에서 `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET`을 상속합니다 — 설정되지 않은 변수는 빈 문자열로 전달되며 shim은 `http://localhost:3111`로 폴백합니다. 한 번 연결한 항목으로 로컬과 원격(k8s / 리버스 프록시) 배포를 모두 커버합니다. + +| 에이전트 | 설정 파일 | 비고 | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | `mcpServers`에 병합. 웹사이트에서 원클릭 deeplink도 사용 가능. | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | `mcpServers`에 병합. 편집 후 Claude Desktop 재시작. | +| **Cline / Roo Code / Kilo Code** | Cline MCP settings (Settings UI → MCP Servers → Edit) | 동일한 `mcpServers` 블록. | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | 동일한 `mcpServers` 블록. | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (자동 병합). | +| **OpenClaw** | OpenClaw MCP config | 동일한 `mcpServers` 블록을 사용하거나, 더 깊은 [memory plugin](../integrations/openclaw/)을 사용. | +| **Codex CLI (MCP only)** | `.codex/config.toml` | TOML 형식: `codex mcp add agentmemory -- npx -y @agentmemory/mcp`, 또는 `[mcp_servers.agentmemory]`를 수동으로 추가. | +| **Codex CLI (full plugin)** | Codex 플러그인 마켓플레이스 | `codex plugin marketplace add rohitg00/agentmemory` 후 `codex plugin add agentmemory@agentmemory`. MCP + 6 lifecycle hooks (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skills 등록. Codex Desktop에서는 [openai/codex#16430](https://github.com/openai/codex/issues/16430)이 머지될 때까지 `agentmemory connect codex --with-hooks`도 실행해야 합니다 — 현재 그곳에서는 플러그인 hooks가 동작하지 않습니다. | +| **OpenCode (MCP only)** | `opencode.json` | 다른 형식 — 최상위 `mcp` 키, 명령은 배열로: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. | +| **OpenCode (full plugin)** | `plugin/opencode/` | 세션 라이프사이클, 메시지, 도구, 오류를 다루는 22개의 자동 캡처 hooks. 두 개의 슬래시 명령(`/recall`, `/remember`). `plugin/opencode/`를 OpenCode workspace에 복사한 후 `opencode.json`에 플러그인 항목을 추가하십시오. 전체 hook 표 + gap 분석은 [`plugin/opencode/README.md`](../plugin/opencode/README.md) 참고. | +| **pi** | `~/.pi/agent/extensions/agentmemory` | [`integrations/pi`](../integrations/pi/)를 복사하고 pi를 재시작. | +| **Hermes Agent** | `~/.hermes/config.yaml` | `memory.provider: agentmemory`로 더 깊은 [memory provider plugin](../integrations/hermes/) 사용. | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen`이 표준 `mcpServers` 블록을 기록. Hook 페이로드는 Claude Code와 필드 호환이므로, 기존 12-hook 스크립트가 수정 없이 동작합니다 — 동일한 `settings.json`의 `hooks` 섹션에서 연결. | +| **Antigravity** (Gemini CLI 대체) | `mcp_config.json` (Antigravity의 User 디렉터리 내) | `agentmemory connect antigravity`가 표준 `mcpServers` 블록을 기록. macOS: `~/Library/Application Support/Antigravity/User/`. Linux: `~/.config/Antigravity/User/`. 2026-06-18 Gemini CLI sunset 이후 사용. | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro`가 사용자 레벨 설정을 기록. 워크스페이스 오버라이드는 코드 옆 `.kiro/settings/mcp.json`에. | +| **Goose** | Goose MCP settings UI | 동일한 `mcpServers` 블록. | +| **Aider** | n/a | REST API와 직접 통신: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. | +| **모든 에이전트 (32+)** | n/a | `npx skillkit install agentmemory`가 호스트를 자동 감지하고 병합. | + +**샌드박스된 MCP 클라이언트**(Flatpak / Snap / 제한적인 컨테이너 등)가 호스트의 `localhost`에 도달할 수 없는 경우: `env` 블록에 `"AGENTMEMORY_FORCE_PROXY": "1"`도 설정하고, `AGENTMEMORY_URL`을 샌드박스가 실제로 도달 가능한 경로(예: LAN IP)로 지정하십시오. + +### 프로그래매틱 액세스 (Python / Rust / Node) + +agentmemory는 핵심 작업을 iii 함수(`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`)로 등록합니다. iii SDK가 있는 모든 언어에서 `ws://localhost:49134`로 직접 호출할 수 있습니다 — 언어별 별도의 REST 클라이언트가 필요하지 않습니다. + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +작동 예제: [`examples/python/`](../examples/python/) (퀵스타트 + 관측/리콜 흐름). iii 런타임이 없는 호스트를 위해 `:3111`의 REST는 그대로 사용 가능합니다. + +### 소스에서 빌드 + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +`iii`가 이미 설치되어 있으면 로컬 `iii-engine`으로 agentmemory를 시작하고, Docker가 사용 가능하면 Docker Compose로 폴백합니다. REST, 스트림, 뷰어는 기본적으로 `127.0.0.1`에 바인딩됩니다. + +`iii-engine`을 수동으로 설치하십시오. **agentmemory는 현재 `iii-engine`을 `v0.11.2`로 고정합니다** — `v0.11.6`은 모든 것을 `iii worker add`를 통해 샌드박스화하는 새 모델을 도입했는데 agentmemory는 아직 이를 위해 리팩터링되지 않았기 때문입니다. 리팩터링이 완료되면 고정이 풀립니다. 수동으로 sandbox 모델로 마이그레이션했다면 `AGENTMEMORY_III_VERSION=`으로 덮어쓰십시오. + +- **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:** `aarch64-apple-darwin`을 `x86_64-apple-darwin`으로 교체 +- **Linux x64:** `x86_64-unknown-linux-gnu`로 교체 +- **Linux arm64:** `aarch64-unknown-linux-gnu`로 교체 +- **Windows:** [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2)에서 `iii-x86_64-pc-windows-msvc.zip`을 다운로드하고 `iii.exe`를 추출한 후 PATH에 추가 + +또는 Docker 사용 (번들된 `docker-compose.yml`이 `iiidev/iii:0.11.2`를 pull). 전체 문서: [iii.dev/docs](https://iii.dev/docs). + +### Windows + +agentmemory는 Windows 10/11에서 실행되지만, Node.js 패키지만으로는 충분하지 않습니다 — 별도의 네이티브 바이너리인 `iii-engine` 런타임이 백그라운드 프로세스로 필요합니다. 공식 업스트림 인스톨러는 `sh` 스크립트이고 PowerShell 인스톨러나 scoop/winget 패키지는 현재 없으므로, Windows 사용자에게는 두 가지 경로가 있습니다: + +**옵션 A — 사전 빌드된 Windows 바이너리 (권장):** + +```powershell +# 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 in your browser +# (we pin to v0.11.2 until agentmemory refactors for the new sandbox +# model that engine v0.11.6+ requires) +# 2. Download iii-x86_64-pc-windows-msvc.zip +# (or iii-aarch64-pc-windows-msvc.zip if you're on an ARM machine) +# 3. Extract iii.exe somewhere on PATH, or place it at: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory checks that location automatically) +# 4. Verify: +iii --version +# Should print: 0.11.2 + +# 5. Then run agentmemory as usual: +npx -y @agentmemory/agentmemory +``` + +**옵션 B — Docker Desktop:** + +```powershell +# 1. Install Docker Desktop for Windows +# 2. Start Docker Desktop and make sure the engine is running +# 3. Run agentmemory — it will auto-start the bundled compose file: +npx -y @agentmemory/agentmemory +``` + +**옵션 C — 독립형 MCP만 사용 (엔진 없음):** 에이전트용 MCP 도구만 필요하고 REST API, 뷰어, cron 작업이 필요하지 않다면 엔진을 완전히 건너뛸 수 있습니다: + +```powershell +npx -y @agentmemory/agentmemory mcp +# or via the shim package: +npx -y @agentmemory/mcp +``` + +**Windows 진단:** `npx @agentmemory/agentmemory`가 실패하면 `--verbose`로 다시 실행하여 실제 엔진 stderr를 확인하십시오. 일반적인 실패 모드: + +| 증상 | 해결 방법 | +|---|---| +| `iii-engine process started`가 표시된 후 `did not become ready within 15s` | 엔진이 시작 시 충돌함 — `--verbose`로 다시 실행하여 stderr 확인 | +| `Could not start iii-engine` | `iii.exe`도 Docker도 설치되어 있지 않음. 위의 옵션 A 또는 B 참고 | +| 포트 충돌 | `netstat -ano \| findstr :3111`로 무엇이 바인딩되어 있는지 확인하고 종료하거나 `--port ` 사용 | +| Docker가 설치되어 있어도 Docker 폴백을 건너뜀 | Docker Desktop이 실제로 실행 중인지 확인 (시스템 트레이 아이콘) | + +> 참고: iii **엔진**은 사전 빌드된 바이너리이며 cargo 크레이트가 아닙니다 — `cargo install`로 설치하려 하지 마세요. (iii **SDK**는 crates.io, npm, PyPI에 게시되어 있지만 agentmemory에는 필요하지 않습니다.) 지원되는 엔진 설치 방법은 모두 v0.11.2에 고정되어 있습니다: 위의 사전 빌드된 v0.11.2 바이너리, 버전 핀**을 포함한** 업스트림 `sh` 설치 스크립트 `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux), 그리고 Docker 이미지 `iiidev/iii:0.11.2`. 그냥 `install.sh | sh`를 실행하면 **최신** 엔진이 설치되는데, agentmemory는 이를 지원하지 않습니다 — 항상 `VERSION=0.11.2`를 전달하세요. 가장 쉬운 방법은 그냥 `npx @agentmemory/agentmemory`를 실행하는 것입니다. 이 명령이 고정된 엔진을 `~/.agentmemory/bin`에 가져다 줍니다. + +--- + +

배포

+ +매니지드 호스트용 원클릭 템플릿입니다. 각각은 npm에서 `@agentmemory/agentmemory`를 가져오고 공식 `iiidev/iii` Docker Hub 이미지에서 iii 엔진 바이너리를 복사하는 자체 완결형 Dockerfile을 제공합니다 — 사전 빌드된 agentmemory 이미지가 필요 없습니다. 영구 스토리지는 `/data`에 마운트되며, 첫 부팅 진입점은 npm 번들 iii 설정(`127.0.0.1`에 바인딩)을 `0.0.0.0`에 바인딩하고 절대 `/data` 경로를 사용하는 배포 튜닝 설정으로 덮어쓰고, HMAC 시크릿을 생성한 후, `gosu`를 통해 `root`에서 `node`로 권한을 낮춘 다음 agentmemory CLI를 exec합니다. + +

+ Deploy to fly.io + Deploy to Railway +

+ +Render의 원클릭 배포 버튼은 저장소 루트에 `render.yaml`이 필요한데, 우리는 의도적으로 이를 깨끗하게 유지합니다. [`deploy/render/`](../deploy/render/README.md)에 문서화된 Render Blueprint 플로우를 사용하여 in-repo blueprint를 수동으로 가리키도록 하십시오. + +전체 설정 세부 사항(HMAC 캡처, 뷰어 SSH 터널, 로테이션, 백업, 비용 하한)은 [`deploy/`](../deploy/README.md)에 있습니다: + +- [`deploy/fly`](../deploy/fly/README.md) — `auto_stop_machines = "stop"`으로 단일 머신; 유휴 비용이 가장 저렴. +- [`deploy/railway`](../deploy/railway/README.md) — Hobby 플랜 정액제, 볼륨은 대시보드에서. +- [`deploy/render`](../deploy/render/README.md) — Blueprint 플로우, 유료 플랜에서 자동 디스크 스냅샷. +- [`deploy/coolify`](../deploy/coolify/README.md) — [Coolify](https://coolify.io/self-hosted)를 통해 자체 VPS에 셀프 호스팅; 동일한 Docker Compose 스택, 호스트와 데이터를 직접 소유. + +`3111` 포트만 게시됩니다. `3113`의 뷰어는 컨테이너 내부에서 loopback에 바인딩된 채로 유지됩니다 — 각 템플릿의 README는 그곳에 도달하기 위한 SSH 터널 패턴을 문서화합니다. + +--- + +

왜 agentmemory인가

+ +모든 코딩 에이전트는 세션이 끝나면 모든 것을 잊습니다. 매 세션의 첫 5분을 스택을 다시 설명하는 데 낭비합니다. agentmemory는 백그라운드에서 실행되어 이를 완전히 제거합니다. + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### 내장 에이전트 메모리와의 비교 + +모든 AI 코딩 에이전트는 내장 메모리와 함께 제공됩니다 — Claude Code에는 `MEMORY.md`가 있고, Cursor에는 notepad가, Cline에는 memory bank가 있습니다. 이들은 포스트잇처럼 동작합니다. agentmemory는 포스트잇 뒤에 있는 검색 가능한 데이터베이스입니다. + +| | 내장 (CLAUDE.md) | agentmemory | +|---|---|---| +| 규모 | 200줄 한도 | 무제한 | +| 검색 | 모든 것을 컨텍스트에 로드 | BM25 + vector + graph (top-K만) | +| 토큰 비용 | 관측 240개 기준 22K+ | ~1,900 토큰 (92% 적음) | +| 크로스 에이전트 | 에이전트별 파일 | MCP + REST (모든 에이전트) | +| 조정 | 없음 | leases, signals, actions, routines | +| 가시성 | 파일 수동 읽기 | :3113의 실시간 뷰어 | + +--- + +

동작 방식

+ +### 메모리 파이프라인 + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4-Tier 메모리 통합 + +인간 뇌가 메모리를 처리하는 방식 — 수면 통합과 크게 다르지 않은 방식 — 에서 영감을 받았습니다. + +| Tier | 무엇 | 비유 | +|------|------|---------| +| **Working** | 도구 사용에서 나온 원시 관측 | 단기 기억 | +| **Episodic** | 압축된 세션 요약 | "무슨 일이 있었는가" | +| **Semantic** | 추출된 사실과 패턴 | "내가 아는 것" | +| **Procedural** | 워크플로우와 의사 결정 패턴 | "그것을 하는 방법" | + +메모리는 시간이 지나면서 감쇠합니다(Ebbinghaus 곡선). 자주 액세스하는 메모리는 강화됩니다. 오래된 메모리는 자동으로 축출됩니다. 모순은 감지되고 해결됩니다. + +### 무엇이 캡처되는가 + +| Hook | 캡처 내용 | +|------|----------| +| `SessionStart` | 프로젝트 경로, 세션 ID | +| `UserPromptSubmit` | 사용자 프롬프트 (개인정보 필터링됨) | +| `PreToolUse` | 파일 접근 패턴 + 풍부한 컨텍스트 | +| `PostToolUse` | 도구 이름, 입력, 출력 | +| `PostToolUseFailure` | 오류 컨텍스트 | +| `PreCompact` | 컴팩션 전에 메모리 재주입 | +| `SubagentStart/Stop` | 서브 에이전트 라이프사이클 | +| `Stop` | 세션 종료 요약 | +| `SessionEnd` | 세션 완료 마커 | + +### 핵심 기능 + +| 기능 | 설명 | +|---|---| +| **자동 캡처** | 모든 도구 사용을 hooks로 기록 — 수동 작업 없음 | +| **시맨틱 검색** | BM25 + vector + 지식 그래프, RRF 융합 | +| **메모리 진화** | 버저닝, supersession, 관계 그래프 | +| **자동 망각** | TTL 만료, 모순 감지, 중요도 기반 축출 | +| **개인정보 우선** | API 키, 시크릿, `` 태그를 저장 전에 제거 | +| **자가 치유** | 서킷 브레이커, 프로바이더 폴백 체인, 헬스 모니터링 | +| **Claude 브리지** | MEMORY.md와의 양방향 동기화 | +| **지식 그래프** | 엔티티 추출 + BFS 순회 | +| **팀 메모리** | 팀원 간 namespaced 공유 + 비공개 | +| **인용 출처 추적** | 모든 메모리를 원본 관측으로 추적 | +| **Git 스냅샷** | 메모리 상태의 버전 관리, 롤백, 차이 비교 | + +--- + + + +세 가지 신호를 결합한 트리플 스트림 검색: + +| 스트림 | 무엇을 하는가 | 언제 | +|---|---|---| +| **BM25** | 형태소 추출 키워드 매칭 + 동의어 확장 | 항상 활성 | +| **Vector** | 밀집 임베딩 위의 코사인 유사도 | 임베딩 프로바이더 구성 시 | +| **Graph** | 엔티티 매칭을 통한 지식 그래프 순회 | 쿼리에서 엔티티 감지 시 | + +Reciprocal Rank Fusion(RRF, k=60)으로 융합하고, 세션 다양화(세션당 최대 3개 결과)합니다. + +BM25는 기본적으로 그리스어, 키릴 문자, 히브리어, 아랍어, 강세 부호가 있는 라틴 문자를 토크나이즈합니다. 중국어 / 일본어 / 한국어 메모리의 경우 선택적 세그멘터(`npm install @node-rs/jieba tiny-segmenter`)를 설치하여 CJK 런을 단어 수준 토큰으로 분할하십시오. 설치하지 않으면 agentmemory는 전체 런 토크나이제이션으로 soft fallback하고 stderr에 일회성 힌트를 출력합니다. + +### 임베딩 프로바이더 + +agentmemory는 프로바이더를 자동 감지합니다. 최상의 결과를 위해 로컬 임베딩을 설치하십시오 (무료): + +```bash +npm install @xenova/transformers +``` + +| 프로바이더 | 모델 | 비용 | 비고 | +|---|---|---|---| +| **Local (권장)** | `all-MiniLM-L6-v2` | 무료 | 오프라인, BM25-only 대비 +8pp recall | +| Gemini | `gemini-embedding-001` | 무료 티어 | 100+ 언어, 768/1536/3072 dims (MRL), 2048-token 입력. `text-embedding-004`를 대체 ([deprecated, 2026년 1월 14일 종료](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | $0.02/1M | 최고 품질 | +| Voyage AI | `voyage-code-3` | 유료 | 코드 최적화 | +| Cohere | `embed-english-v3.0` | 무료 평가판 | 범용 | +| OpenRouter | 모든 모델 | 다양 | 멀티 모델 프록시 | + +--- + +

MCP 서버

+ +53개 도구, 6개 리소스, 3개 프롬프트, 4개 skills — 모든 에이전트를 위한 가장 포괄적인 MCP 메모리 툴킷. + +> **MCP shim 대 전체 서버:** 게시된 `@agentmemory/mcp` 패키지는 얇은 shim입니다. `AGENTMEMORY_URL`을 통해 실행 중인 agentmemory 서버에 도달할 수 있을 때 **만** 전체 51-도구 표면을 노출합니다(프록시 모드). 도달 가능한 서버가 없으면 shim은 7-도구 로컬 세트(`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`)로 폴백합니다. `AGENTMEMORY_TOOLS=core|all` 환경 변수는 *서버 측* 플래그입니다 — shim의 `env` 블록에 설정해도 효과가 없습니다. Cursor / OpenCode / Gemini CLI에서 도구가 7개만 보인다면 `npx @agentmemory/agentmemory`(또는 Docker 스택)를 시작하고 `AGENTMEMORY_URL=http://localhost:3111`을 설정하십시오. + +### 51개 도구 + +
+핵심 도구 (항상 사용 가능) + +| 도구 | 설명 | +|------|-------------| +| `memory_recall` | 과거 관측 검색 | +| `memory_compress_file` | 구조를 유지하면서 markdown 파일 압축 | +| `memory_save` | 통찰, 결정, 패턴 저장 | +| `memory_patterns` | 반복 패턴 감지 | +| `memory_smart_search` | 하이브리드 시맨틱 + 키워드 검색 | +| `memory_file_history` | 특정 파일에 대한 과거 관측 | +| `memory_sessions` | 최근 세션 목록 | +| `memory_timeline` | 시간순 관측 | +| `memory_profile` | 프로젝트 프로필 (개념, 파일, 패턴) | +| `memory_export` | 모든 메모리 데이터 내보내기 | +| `memory_relations` | 관계 그래프 쿼리 | + +
+ +
+확장 도구 (총 51개 — AGENTMEMORY_TOOLS=all 설정) + +| 도구 | 설명 | +|------|-------------| +| `memory_patterns` | 반복 패턴 감지 | +| `memory_timeline` | 시간순 관측 | +| `memory_relations` | 관계 그래프 쿼리 | +| `memory_graph_query` | 지식 그래프 순회 | +| `memory_consolidate` | 4-tier 통합 실행 | +| `memory_claude_bridge_sync` | MEMORY.md와 동기화 | +| `memory_team_share` | 팀원과 공유 | +| `memory_team_feed` | 최근 공유 항목 | +| `memory_audit` | 작업 감사 로그 | +| `memory_governance_delete` | 감사 로그를 남기는 삭제 | +| `memory_snapshot_create` | Git 버전 관리 스냅샷 | +| `memory_action_create` | 의존성이 있는 작업 항목 생성 | +| `memory_action_update` | 작업 상태 업데이트 | +| `memory_frontier` | 우선순위로 정렬된 차단 해제된 작업 | +| `memory_next` | 가장 중요한 다음 작업 하나 | +| `memory_lease` | 독점 작업 leases (멀티 에이전트) | +| `memory_routine_run` | 워크플로우 루틴 인스턴스화 | +| `memory_signal_send` | 에이전트 간 메시징 | +| `memory_signal_read` | 수신 확인이 있는 메시지 읽기 | +| `memory_checkpoint` | 외부 조건 게이트 | +| `memory_mesh_sync` | 인스턴스 간 P2P 동기화 | +| `memory_sentinel_create` | 이벤트 기반 워처 | +| `memory_sentinel_trigger` | 외부에서 sentinel 발화 | +| `memory_sketch_create` | 일시적 작업 그래프 | +| `memory_sketch_promote` | 영구로 승격 | +| `memory_crystallize` | 작업 체인 압축 | +| `memory_diagnose` | 헬스 체크 | +| `memory_heal` | 정체된 상태 자동 수정 | +| `memory_facet_tag` | dimension:value 태그 | +| `memory_facet_query` | facet 태그로 쿼리 | +| `memory_verify` | 출처 추적 | + +
+ +### 6 리소스 · 3 프롬프트 · 4 Skills + +| 유형 | 이름 | 설명 | +|------|------|-------------| +| Resource | `agentmemory://status` | 헬스, 세션 수, 메모리 수 | +| Resource | `agentmemory://project/{name}/profile` | 프로젝트별 인텔리전스 | +| Resource | `agentmemory://memories/latest` | 최신 10개 활성 메모리 | +| Resource | `agentmemory://graph/stats` | 지식 그래프 통계 | +| Prompt | `recall_context` | 검색 + 컨텍스트 메시지 반환 | +| Prompt | `session_handoff` | 에이전트 간 핸드오프 데이터 | +| Prompt | `detect_patterns` | 반복 패턴 분석 | +| Skill | `/recall` | 메모리 검색 | +| Skill | `/remember` | 장기 메모리에 저장 | +| Skill | `/session-history` | 최근 세션 요약 | +| Skill | `/forget` | 관측/세션 삭제 | + +### 독립형 MCP + +전체 서버 없이 실행 — 모든 MCP 클라이언트용. 다음 둘 다 동작합니다: + +```bash +npx -y @agentmemory/agentmemory mcp # canonical (always available) +npx -y @agentmemory/mcp # shim package alias +``` + +또는 에이전트의 MCP 설정에 추가: + +대부분의 에이전트 (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +파일을 교체하지 말고 호스트의 기존 `mcpServers` 객체에 `agentmemory` 항목을 병합하십시오. 호스트의 `localhost`에 도달할 수 없는 샌드박스 클라이언트의 경우 env 블록에 `"AGENTMEMORY_FORCE_PROXY": "1"`을 추가하고 `AGENTMEMORY_URL`을 샌드박스가 도달할 수 있는 경로로 설정하십시오. + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +저장소에서 플러그인 파일을 복사하십시오: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

실시간 뷰어

+ +`3113` 포트에서 자동 시작됩니다. 라이브 관측 스트림, 세션 탐색기, 메모리 브라우저, 지식 그래프 시각화, 헬스 대시보드. + +```bash +open http://localhost:3113 +``` + +뷰어 서버는 기본적으로 `127.0.0.1`에 바인딩됩니다. REST가 서빙하는 `/agentmemory/viewer` 엔드포인트는 일반 `AGENTMEMORY_SECRET` bearer-token 규칙을 따릅니다. CSP 헤더는 응답별 script nonce를 사용하며 인라인 핸들러 속성을 비활성화합니다 (`script-src-attr 'none'`). + +--- + +

iii Console

+ +`:3113`의 뷰어는 에이전트가 **기억한 것**을 보여줍니다. [iii console](https://iii.dev/docs/console)은 에이전트가 **무엇을 했는지**를 보여줍니다 — 모든 메모리 작업을 OpenTelemetry 추적으로, 모든 KV 항목을 편집 가능하게, 모든 함수를 호출 가능하게, 모든 스트림을 탭 가능하게. 동일한 메모리에 대한 두 창: 하나는 제품 형태, 하나는 엔진 형태. + +`memory_smart_search`가 발화되는 것을 보고 BM25 스캔 → 임베딩 조회 → RRF 융합 → 리랭커를 워터폴로 확인하십시오. KV 브라우저에서 정체된 통합 타이머를 편집하십시오. 조정된 페이로드로 `PostToolUse` hook을 재생하십시오. WebSocket 스트림을 고정하고 관측이 실시간으로 도착하는 것을 지켜보십시오. + +agentmemory는 모든 함수, 트리거, 상태 스코프, 스트림이 iii 프리미티브이기 때문에 — 사용자 정의도, 계측할 것도 없기 때문에 — 이를 무료로 제공합니다. + +

+ iii console Workers 페이지 — 연결된 워커들, 라이브 함수 수와 런타임 메타데이터가 표시된 agentmemory 인스턴스 포함 +
+ Workers 페이지: 연결된 모든 워커 — agentmemory 자체 포함 — PID, 함수 수, 런타임, last-seen 표시. +

+ +**이미 설치됨.** 콘솔은 `iii`와 함께 제공됩니다 — 별도의 인스톨러가 없습니다. + +**agentmemory와 함께 실행:** + +```bash +# agentmemory viewer holds port 3113, so run the console on 3114. +# Engine REST (3111), WebSocket (3112), and bridge (49134) defaults match agentmemory. +iii console --port 3114 +``` + +그런 다음 `http://localhost:3114`을 여십시오. 실험적인 architecture-graph 페이지를 위해 `--enable-flow`를 추가하십시오. + +엔진 엔드포인트를 옮긴 경우에만 덮어쓰십시오: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**콘솔에서 할 수 있는 일:** + +| 페이지 | 용도 | +|------|-----------| +| **Workers** | 연결된 모든 워커와 그 라이브 메트릭 확인 — agentmemory 워커 자체 포함. | +| **Functions** | JSON 페이로드로 agentmemory의 모든 함수를 직접 호출 — 클라이언트를 연결하지 않고 `memory.recall`, `memory.consolidate`, `graph.query`를 테스트하기에 편리. | +| **Triggers** | HTTP, cron, event, state 트리거를 재생 — 통합 cron을 수동으로 발화, HTTP 라우트를 재시도, state 변경을 발생. | +| **States** | 전체 CRUD가 가능한 KV 브라우저 — 세션, 메모리 슬롯, 라이프사이클 타이머, 임베딩 인덱스 — 값을 그 자리에서 편집. | +| **Streams** | iii 스트림을 통해 흐르는 메모리 쓰기, hook 이벤트, 관측 업데이트를 위한 라이브 WebSocket 모니터. | +| **Queues** | 내구성 있는 큐 토픽 + 데드 레터 관리. 실패한 임베딩 / 압축 작업을 재생하거나 폐기. | +| **Traces** | OpenTelemetry 워터폴 / 플레임 / 서비스별 분해 뷰. `trace_id`로 필터링하여 단일 `memory.search`가 생성한 함수, DB 호출, 임베딩 요청을 정확히 확인. | +| **Logs** | trace/span ID에 필터링·상관된 구조화된 OTEL 로그. | +| **Config** | 런타임 설정 — 엔진이 실행 중인 워커, 프로바이더, 포트를 정확히 확인. | +| **Flow** | (선택, `--enable-flow`) 모든 워커, 트리거, 스트림의 인터랙티브 architecture graph. | + +

+ span별 지속 시간을 보여주는 iii console trace waterfall view +
+ Traces: 모든 메모리 작업에 대한 워터폴 / 플레임 / 서비스 분해. +

+ +**Traces는 이미 켜져 있습니다:** + +`iii-config.yaml`은 `iii-observability` 워커가 활성화된 상태로 제공됩니다(`exporter: memory`, `sampling_ratio: 1.0`, metrics + logs). 추가 설정이 필요 없습니다 — agentmemory가 시작되는 순간 모든 메모리 작업이 콘솔이 읽을 수 있는 trace span과 구조화된 로그를 방출합니다. + +대신 Jaeger/Honeycomb/Grafana Tempo로 내보내고 싶다면 `exporter: memory`를 `exporter: otlp`로 변경하고 iii의 가시성 문서에 따라 collector 엔드포인트를 설정하십시오. + +> **참고:** 콘솔 자체에는 인증이 적용되지 않습니다 — `127.0.0.1`에 바인딩된 채로 두고(기본값) 절대 공개적으로 노출하지 마십시오. + +--- + +

Powered by iii

+ +agentmemory는 **이미 실행 중인 [iii](https://iii.dev) 인스턴스**입니다. 함수, 트리거, KV 상태, 스트림, OTEL 추적 — 모두 iii 프리미티브입니다. Postgres, Redis, Express, pm2, Prometheus를 설치하지 않은 이유는 iii가 이들을 대체하기 때문입니다. + +그 말은 명령어 하나로 agentmemory에 완전히 새로운 기능을 확장할 수 있다는 뜻입니다. + +### 명령어 하나로 agentmemory 확장 + +```bash +iii worker add iii-pubsub # fan memory writes out to every connected instance +iii worker add iii-cron # scheduled consolidation, decay sweeps, snapshot rotation +iii worker add iii-queue # durable retries for embedding + compression jobs +iii worker add iii-observability # OTEL traces on every memory op (default on) +iii worker add iii-sandbox # run recalled code inside an isolated microVM +iii worker add iii-database # swap in a SQL-backed state adapter +iii worker add mcp # generic MCP host alongside the agentmemory MCP +``` + +각 `iii worker add`는 agentmemory가 이미 실행 중인 동일한 엔진에 새 함수와 트리거를 등록합니다. 뷰어와 콘솔은 즉시 이를 인식합니다 — 재로드도, 새 통합도, 새 컨테이너도 필요 없습니다. + +| `iii worker add` | agentmemory 위에 무엇이 추가되는가 | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | 멀티 인스턴스 메모리: 모든 `remember`가 팬아웃, 모든 `search`가 합집합을 읽음 | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | 스케줄링된 라이프사이클 — 야간 통합, 주간 스냅샷, 고정된 시계에 따른 감쇠 | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | 내구성 있는 재시도: 실패한 임베딩 + 압축 작업은 재시작에도 살아남아 관측 손실 없음 | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | 모든 함수에 OTEL traces, metrics, logs — 첫날부터 `iii-config.yaml`에 연결됨 | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | `memory_recall`에서 나온 코드를 셸이 아니라 일회용 VM 안에서 실행 | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | 인메모리 KV 기본값을 넘어설 때 SQL 기반 state adapter | +| [`mcp`](https://workers.iii.dev/workers/mcp) | agentmemory의 MCP 옆에 추가 MCP 서버를 세우고 동일한 엔진을 공유 | + +전체 레지스트리: [workers.iii.dev](https://workers.iii.dev). 그곳의 모든 워커는 agentmemory가 사용하는 동일한 프리미티브로 구성됩니다 — 그리고 이미 갖고 있는 agentmemory도 그중 하나입니다. + +### iii가 무엇을 대체하는가 + +| 전통적인 스택 | agentmemory에서의 사용 | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + 인메모리 벡터 인덱스 | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | iii engine worker supervision | +| Prometheus / Grafana | iii OTEL + 헬스 모니터 | +| 사용자 정의 플러그인 시스템 | `iii worker add ` | + +**118개 소스 파일 · ~21,800 LOC · 950+ tests · 123개 함수 · 34개 KV 스코프** — 모두 세 가지 프리미티브 위에. `agentmemory plugin install`이 없습니다. 플러그인 시스템은 iii 자체입니다. + +--- + +

설정

+ +### LLM 프로바이더 + +agentmemory는 환경에서 자동 감지합니다. 기본적으로 프로바이더를 구성하거나 Claude subscription 폴백에 명시적으로 옵트인하지 않는 한 LLM 호출이 발생하지 않습니다. + +| 프로바이더 | 설정 | 비고 | +|----------|--------|-------| +| **No-op (기본)** | 설정 불필요 | LLM 기반 압축/요약이 비활성화됨. 합성 BM25 압축 + 리콜은 여전히 동작. 이전에 Claude-subscription 폴백에 의존했다면 아래의 `AGENTMEMORY_ALLOW_AGENT_SDK` 참고. | +| Anthropic API | `ANTHROPIC_API_KEY` | 토큰당 청구 | +| MiniMax | `MINIMAX_API_KEY` | Anthropic 호환 | +| Gemini | `GEMINI_API_KEY` | 임베딩도 활성화 | +| OpenRouter | `OPENROUTER_API_KEY` | 모든 모델 | +| Claude subscription 폴백 | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | 옵트인 전용. `@anthropic-ai/claude-agent-sdk` 세션을 스폰 — 무한 Stop-hook 재귀를 일으킨 전력이 있어서 더 이상 기본값이 아닙니다. | + +### 비용 인식 모델 선택 + +백그라운드 압축은 모든 관측마다 실행되므로 모델 선택이 월별 지출에 의미 있게 영향을 미칩니다. 캡처된 워크로드 데이터: 635 요청 / 888K 토큰 / 35시간의 활성 사용, 2026-05-23 가격으로 세 가지 OpenRouter 모델에 대해 실행. + +| 티어 | 모델 | Input / 1M | Output / 1M | 캡처된 35h 비용 | 비고 | +|------|-------|------------|-------------|---------------------------|-------| +| 권장 | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | 견고한 압축 + 요약 품질, Sonnet 대비 ~10배 저렴. | +| 권장 | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | 더 오래되었지만 압축 전용 워크로드에 여전히 적합. | +| 권장 | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | 세션이 코드 중심이라면 강한 코드 추론. | +| 프리미엄 | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | 고품질이지만 항시 백그라운드 작업에는 비쌈. | +| 프리미엄 | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | Sonnet과 유사한 티어. | +| 회피 | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | 추론 클래스 모델; 압축에는 막대한 과지출. | + +`OPENROUTER_MODEL`이 프리미엄 티어 패턴과 일치하면 agentmemory가 런타임 경고를 출력합니다. 정보에 기반한 결정을 내렸다면 `AGENTMEMORY_SUPPRESS_COST_WARNING=1`로 한 번에 침묵시키십시오. + +메모리 작업에서의 품질 대 비용 트레이드오프: 압축은 비교적 느슨한 품질 기준을 가진 요약 작업입니다(사용자가 아니라 에이전트가 요약을 다시 읽습니다). DeepSeek-V4-Pro / Qwen3-Coder는 이 작업에서 Sonnet과 반올림 오차 내에 들어가면서 ~10배 적은 비용이 듭니다. 프리미엄 티어 모델은 직접 읽는 쿼리에 남겨두십시오. + +출처: [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/). + +### 멀티 에이전트 메모리 (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +여러 역할(architect / developer / reviewer / researcher / support-agent)이 하나의 agentmemory 서버를 공유하는 멀티 에이전트 설정에서, `AGENT_ID`는 모든 쓰기에 그것을 작성한 역할을 태깅합니다. `AGENTMEMORY_AGENT_SCOPE`는 리콜이 그 태그로 필터링되는지 여부를 제어합니다. + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" +``` + +두 가지 모드: + +| 모드 | 쓰기 태그 | 리콜 필터링 | 사용 시점 | +|------|------------|---------------|-------------| +| `shared` (기본) | 예 | 아니오 | 감사 로그가 있는 크로스 에이전트 컨텍스트. architect는 developer가 기록한 내용을 볼 수 있지만, 모든 행은 누가 말했는지 기록합니다. | +| `isolated` | 예 | 예 | 엄격한 분리. architect는 developer의 관측 / 메모리 / 세션을 절대 보지 않습니다. | + +`AGENT_ID`가 설정되었을 때 태깅되는 것: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. 역할은 `api::session::start` → `mem::observe` → `mem::compress` → KV로 흐릅니다. + +isolated 모드에서 필터링되는 것: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. 각 엔드포인트는 요청별로 덮어쓰기 위해 `?agentId=`을 받고, env 스코프를 완전히 옵트아웃하기 위해 `?agentId=*`을 받습니다. `/memories`는 또한 `agentId`가 undefined인 pre-AGENT_ID 메모리를 노출하기 위해 `?includeOrphans=true`를 받습니다. + +SDK / REST 레이어에서의 호출별 덮어쓰기: 모든 변형 엔드포인트(`/session/start`, `/remember`)는 env를 이기는 `agentId` 필드를 request body에서 받습니다. 많은 역할을 하나의 서버 프로세스로 라우팅하는 런타임에 유용합니다. + +`AGENT_ID`가 설정되지 않았을 때, 메모리는 스코프되지 않은 상태로 유지됩니다(레거시 동작, 태그 없음, 필터 없음). + +### 포트 + +agentmemory + iii-engine은 기본적으로 네 개의 포트에 바인딩합니다. 재시작이 `port in use`로 실패한다면, 이 표가 어떤 프로세스를 찾을지 알려줍니다. + +| 포트 | 프로세스 | 용도 | Env 덮어쓰기 | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | 내부 streams 워커 (agentmemory + 뷰어가 소비) | `III_STREAMS_PORT` | +| `3113` | agentmemory | 실시간 뷰어 (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — 워커가 여기에 등록, OTel 텔레메트리가 이 위로 흐름 | `III_ENGINE_URL` (전체 URL, 기본 `ws://localhost:49134`) | + +크래시된 실행 후 포트가 바인딩된 채로 남아 있을 때의 정리: + +```bash +# macOS / Linux — find whatever is on each port and kill it +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop`은 정상 종료 시 워커와 엔진 pidfile을 모두 깔끔하게 회수합니다. 위의 수동 정리는 어떤 pidfile도 남지 않은 크래시 후 케이스에만 해당됩니다. + +### 설정 파일 + +매 셸에서 변수를 export하는 대신 agentmemory 런타임 설정을 `~/.agentmemory/.env`에 두십시오. 뷰어가 `export ANTHROPIC_API_KEY=...` 같은 setup 힌트를 보여주면, `export` 접두사 없이 `ANTHROPIC_API_KEY=...`로 이 파일에 복사한 후 agentmemory를 재시작하십시오. + +프로세스 환경 변수는 여전히 동작하며 파일의 값보다 우선순위가 높습니다. + +Windows에서 동일한 파일은 `%USERPROFILE%\.agentmemory\.env`에 있습니다: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +API 키 대신 Claude Code Pro/Max subscription으로 테스트하려면 명시적으로 옵트인하십시오: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +원한다면 동일한 파일에서 graph 또는 consolidation 기능을 활성화하십시오: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### 환경 변수 + +`~/.agentmemory/.env` 생성: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +`3111` 포트의 124개 엔드포인트. REST API는 기본적으로 `127.0.0.1`에 바인딩됩니다. 보호된 엔드포인트는 `AGENTMEMORY_SECRET`이 설정되었을 때 `Authorization: Bearer `를 요구하며, mesh sync 엔드포인트는 양쪽 피어 모두에서 `AGENTMEMORY_SECRET`을 요구합니다. + +
+주요 엔드포인트 + +| Method | Path | 설명 | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | 헬스 체크 (항상 공개) | +| `POST` | `/agentmemory/session/start` | 세션 시작 + 컨텍스트 가져오기 | +| `POST` | `/agentmemory/session/end` | 세션 종료 | +| `POST` | `/agentmemory/observe` | 관측 캡처 | +| `POST` | `/agentmemory/smart-search` | 하이브리드 검색 | +| `POST` | `/agentmemory/context` | 컨텍스트 생성 | +| `POST` | `/agentmemory/remember` | 장기 메모리에 저장 | +| `POST` | `/agentmemory/forget` | 관측 삭제 | +| `POST` | `/agentmemory/enrich` | 파일 컨텍스트 + 메모리 + 버그 | +| `GET` | `/agentmemory/profile` | 프로젝트 프로필 | +| `GET` | `/agentmemory/export` | 모든 데이터 내보내기 | +| `POST` | `/agentmemory/import` | JSON에서 가져오기 | +| `POST` | `/agentmemory/graph/query` | 지식 그래프 쿼리 | +| `POST` | `/agentmemory/team/share` | 팀과 공유 | +| `GET` | `/agentmemory/audit` | 감사 로그 | + +전체 엔드포인트 목록: [`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

개발

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 950+ tests +npm run test:integration # API tests (requires running services) +``` + +**전제 조건:** Node.js >= 20, [iii-engine](https://iii.dev/docs) 또는 Docker + +

라이선스

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.pt-BR.md b/READMEs/README.pt-BR.md new file mode 100644 index 0000000..e9bbc32 --- /dev/null +++ b/READMEs/README.pt-BR.md @@ -0,0 +1,1369 @@ +

+ agentmemory — Memória persistente para agentes de codificação com IA +

+ +

+ + Seu agente de codificação lembra de tudo. Chega de re-explicar. + Built on iii engine +
+ Memória persistente para Claude Code, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode e qualquer cliente MCP. +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Documento de design: 1200 stars / 172 forks no gist +

+ +

+ O gist estende o padrão LLM Wiki do Karpathy com pontuação de confiança, ciclo de vida, grafos de conhecimento e busca híbrida: agentmemory é a implementação. +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ Demonstração do agentmemory +

+ +

+ Instalação • + Início rápido • + Benchmarks • + Comparativo • + Agentes • + Como funciona • + MCP • + Viewer • + iii Console • + Powered by iii • + Configuração • + API +

+ +--- + +## Install + +```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 connect claude-code # wire your agent (also: codex, cursor, gemini-cli, ...) +``` + +Ou via `npx` (sem instalação): + +```bash +npx @agentmemory/agentmemory +``` + +Atenção — o npx faz cache por versão. Se um simples `npx @agentmemory/agentmemory` servir uma release antiga, force a mais recente com `npx -y @agentmemory/agentmemory@latest`, ou limpe o cache uma vez com `rm -rf ~/.npm/_npx` (macOS/Linux; no Windows apague `%LOCALAPPDATA%\npm-cache\_npx`). A primeira execução via npx a partir da v0.9.16+ pergunta inline se você quer instalar globalmente, de modo que o comando `agentmemory` simples funcione em qualquer lugar depois. + +Opções completas em [Início rápido](#quick-start) abaixo. Conexão específica por agente em [Funciona com qualquer agente](#works-with-every-agent). + +--- + +

Funciona com qualquer agente

+ +agentmemory funciona com qualquer agente que suporte hooks, MCP ou REST API. Todos os agentes compartilham o mesmo servidor de memória. + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+native plugin + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+native plugin + 6 hooks + MCP +
+OpenClaw
+OpenClaw
+native plugin + MCP +
+Hermes
+Hermes
+native plugin + MCP +
+pi
+pi
+native plugin + MCP +
+OpenHuman
+OpenHuman
+native Memory trait backend +
+Cursor
+Cursor
+MCP server +
+Gemini CLI
+Gemini CLI
+MCP server +
+OpenCode
+OpenCode
+22 hooks + MCP + plugin +
+Cline
+Cline
+MCP server +
+Goose
+Goose
+MCP server +
+Kilo Code
+Kilo Code
+MCP server +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP server +
+Windsurf
+Windsurf
+MCP server +
+Roo Code
+Roo Code
+MCP server +
+ +

+ Funciona com qualquer agente que fale MCP ou HTTP. Um servidor, memórias compartilhadas entre todos eles. +

+ +--- + +Você explica a mesma arquitetura toda sessão. Você redescobre os mesmos bugs. Você reensina as mesmas preferências. A memória integrada (CLAUDE.md, .cursorrules) bate no teto das 200 linhas e fica desatualizada. agentmemory resolve isso. Ele captura silenciosamente o que seu agente faz, comprime em memória pesquisável e injeta o contexto certo quando a próxima sessão começa. Um comando. Funciona em todos os agentes. + +**O que muda:** Na sessão 1 você configura autenticação JWT. Na sessão 2 você pede rate limiting. O agente já sabe que sua autenticação usa o middleware jose em `src/middleware/auth.ts`, que seus testes cobrem a validação de tokens e que você escolheu jose em vez de jsonwebtoken por compatibilidade com Edge. Sem re-explicar. Sem copiar e colar. O agente simplesmente *sabe*. + +```bash +npx @agentmemory/agentmemory +``` + +> **Novidade na v0.9.0** — Landing em [agent-memory.dev](https://agent-memory.dev), conector de filesystem (`@agentmemory/fs-watcher`), MCP standalone agora faz proxy para o servidor em execução, então hooks e viewer combinam, política de auditoria codificada em cada caminho de deleção, e health para de marcar `memory_critical` em processos Node pequenos. Notas completas em [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18). + +--- + +

Benchmarks

+ + + + + + +
+ +### Precisão de recuperação + +**coding-agent-life-v1** (corpus interno, reproduzível em sandbox) + +| Adaptador | P@5 | R@5 | Taxa de acerto top-5 | Latência p50 | +|---|---|---|---|---| +| **agentmemory hybrid** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep baseline | 0.267 | 0.967 | 15 / 15 | 0 ms | + +Taxa de acerto top-5 de 100%. **2,2×** mais precisão que a baseline grep com a mesma entrada. Detalhamento completo por tipo: [`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 perguntas) + +| Sistema | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| BM25-only fallback | 86.2% | 94.6% | 71.5% | + + + +### Economia de tokens + +| Abordagem | Tokens/ano | Custo/ano | +|---|---|---| +| Colar contexto completo | 19.5M+ | Impossível (excede a janela) | +| Resumido por LLM | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + embeddings locais | ~170K | **$0** | + +
+ +> Modelo de embedding: `all-MiniLM-L6-v2` (local, gratuito, sem API key). Relatórios completos: [`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](../benchmark/QUALITY.md), [`benchmark/SCALE.md`](../benchmark/SCALE.md). Comparativo com concorrentes: [`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory vs mem0, Letta, Khoj, claude-mem, Hippo. + +**Reproduza localmente:** [`eval/README.md`](../eval/README.md) — harness com adaptadores plugáveis para LongMemEval `_s` (500-Q públicas) e `coding-agent-life-v1` (corpus interno de 15 sessões). Adaptadores grep / vector / agentmemory são pontuados lado a lado, saída em NDJSON, e as scorecards publicadas ficam em [`docs/benchmarks/`](../docs/benchmarks/). + +**Combina com [codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything) e [Graphify](https://github.com/safishamsi/graphify).** Indexação de grafos de código, pipelines de build multiagente e grafos de conhecimento mais amplos sobre docs / PDFs / imagens / vídeos. agentmemory lembra do trabalho; esses três projetos iluminam o resto da camada de contexto. Recipes e tabela de roteamento por pergunta: [`docs/recipes/pairings.md`](../docs/recipes/pairings.md). + +--- + +

Comparativo

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)Built-in (CLAUDE.md)
TipoEngine de memória + servidor MCPAPI de camada de memóriaRuntime de agente completoArquivo estático
Retrieval R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/A (grep)
Captura automática12 hooks (esforço manual zero)Chamadas manuais a add()O agente se autoeditaEdição manual
BuscaBM25 + Vector + Graph (fusão RRF)Vector + GraphVector (archival)Carrega tudo no contexto
MultiagenteMCP + REST + leases + signalsAPI (sem coordenação)Somente dentro do runtime do LettaArquivos por agente
Dependência de frameworkNenhuma (qualquer cliente MCP)NenhumaAlta (precisa usar Letta)Formato por agente
Dependências externasNenhuma (SQLite + iii-engine)Qdrant / pgvectorPostgres + BD vetorialNenhuma
Ciclo de vida da memóriaConsolidação de 4 níveis + decaimento + auto-esquecimentoExtração passivaGerenciado pelo agentePoda manual
Eficiência de tokens~1.900 tokens/sessão ($10/ano)Varia conforme a integraçãoMemória principal no contexto22K+ tokens com 240 obs
Viewer em tempo realSim (port 3113)Dashboard na nuvemDashboard na nuvemNão
Self-hostedSim (padrão)OpcionalOpcionalSim
+ +--- + +

Início rápido

+ +Compatibilidade: este release tem como alvo o `iii-sdk` estável `^0.11.0` e o iii-engine v0.11.x. + +### Experimente em 30 segundos + +```bash +# Terminal 1: start the server +npx @agentmemory/agentmemory + +# Terminal 2: seed sample data and see recall in action +npx @agentmemory/agentmemory demo +``` + +`demo` semeia 3 sessões realistas (autenticação JWT, correção de N+1 queries, rate limiting) e roda buscas semânticas sobre elas. Você verá que ele encontra "N+1 query fix" ao buscar "database performance optimization" — algo que matching por palavra-chave não consegue fazer. + +Abra `http://localhost:3113` para acompanhar a memória sendo construída ao vivo. + +### Recomendado: instale globalmente + +`npx` faz cache por versão. Se você rodou `npx @agentmemory/agentmemory@0.9.14` semana passada, um simples `npx @agentmemory/agentmemory` pode servir a versão velha 0.9.14 a partir de `~/.npm/_npx/`, e não a mais recente. Instale uma vez e o comando `agentmemory` funciona em qualquer lugar: + +```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 stop # tear it down +agentmemory remove # uninstall everything we created +agentmemory connect claude-code # wire one agent +agentmemory doctor # interactive diagnostics + fix prompts +``` + +A partir da v0.9.16, a primeira execução via npx pergunta inline se você quer instalar globalmente — responda `Y` uma vez e está pronto. Se pular, recorra a qualquer um destes para um fetch limpo: + +```bash +npx -y @agentmemory/agentmemory@latest # forces latest from npm (cross-platform) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # macOS/Linux only (POSIX shell) +``` + +No Windows / PowerShell, o equivalente para limpar cache é `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` — a forma `npx -y ...@latest` acima é a opção multiplataforma. + +### Session Replay + +Toda sessão que o agentmemory grava é reproduzível. Abra o viewer, escolha a aba **Replay** e arraste pela timeline: prompts, chamadas a tools, resultados e respostas renderizam como eventos discretos com play/pause, controle de velocidade (0,5×–4×) e atalhos de teclado (espaço para alternar, setas para avançar passo a passo). + +Já tem transcripts antigos JSONL do Claude Code que quer trazer para cá? + +```bash +# Import everything under the default ~/.claude/projects +npx @agentmemory/agentmemory import-jsonl + +# Or import a single file +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +As sessões importadas aparecem no seletor de Replay ao lado das nativas. Sob o capô, cada entrada passa pelas funções iii `mem::replay::load`, `mem::replay::sessions` e `mem::replay::import-jsonl` — sem servidores paralelos. + +### Atualização / Manutenção + +Use o comando de manutenção quando você intencionalmente quiser atualizar seu runtime local: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +Aviso: este comando muta o workspace/runtime atual. Pode atualizar dependências JavaScript e puxar a imagem Docker fixada `iiidev/iii:0.11.2`. Nunca instala um engine iii sem fixação ou mais recente. + +Detalhes de implementação estão em `src/cli.ts` (veja `runUpgrade` na região `src/cli.ts:544-595`). + +### Claude Code (um bloco, cole) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Claude Code sem instalar o plugin (caminho MCP standalone) + +Se você cabear o servidor MCP do agentmemory via `~/.claude.json` diretamente em vez de usar `/plugin install`, o Claude Code nunca resolve `${CLAUDE_PLUGIN_ROOT}` e você tem que apontar os scripts de hook para caminhos absolutos em `~/.claude/settings.json`. Esses caminhos tipicamente embutem a versão do agentmemory (ex: `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), então a próxima atualização quebra silenciosamente todos os hooks. + +Contorno: + +```bash +agentmemory connect claude-code --with-hooks +``` + +Isso mescla os mesmos comandos de hook em `~/.claude/settings.json` com caminhos absolutos resolvidos para o diretório `plugin/` empacotado do pacote `@agentmemory/agentmemory` atualmente instalado. Rode o comando novamente após atualizar o agentmemory para atualizar os caminhos. Entradas de usuário no mesmo arquivo são preservadas; apenas entradas anteriores do agentmemory são substituídas. Usar o caminho `/plugin install` continua sendo a abordagem recomendada. +Para deploys remotos ou protegidos, inicie o Claude Code com `AGENTMEMORY_URL` e `AGENTMEMORY_SECRET` definidos. O plugin repassa ambos os valores para seu servidor MCP empacotado; quando `AGENTMEMORY_URL` está vazio, o shim MCP usa `http://localhost:3111`. + +### Codex CLI (plataforma de plugins do Codex) + +```bash +# 1. start the memory server in a separate terminal +npx @agentmemory/agentmemory + +# 2. register the agentmemory marketplace and install the plugin +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +O plugin do Codex é servido a partir do mesmo diretório `plugin/` do plugin do Claude Code. Ele registra: + +- `@agentmemory/mcp` como servidor MCP (faz proxy de todas as 51 tools quando `AGENTMEMORY_URL` aponta para um servidor agentmemory em execução; cai para 7 tools localmente quando não há servidor acessível) +- 6 hooks de ciclo de vida: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 4 skills: `/recall`, `/remember`, `/session-history`, `/forget` + +A engine de hooks do Codex injeta `CLAUDE_PLUGIN_ROOT` nos subprocessos de hook (conforme [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)), então os mesmos scripts de hook funcionam nos dois hosts sem duplicação. Os eventos Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure são exclusivos do Claude Code e não são registrados para o Codex. + +#### Codex Desktop: hooks do plugin atualmente silenciosos (com contorno) + +`CodexHooks` e `PluginHooks` são estáveis e habilitados por padrão em [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs), mas as builds atuais do Codex Desktop não despacham `hooks.json` local de plugin ([openai/codex#16430](https://github.com/openai/codex/issues/16430)). As tools MCP continuam funcionando; só as observações de ciclo de vida estão faltando. + +Até que o upstream corrija, espelhe os mesmos comandos de hook no `~/.codex/hooks.json` global: + +```bash +agentmemory connect codex --with-hooks +``` + +Isso adiciona um bloco idempotente em `~/.codex/hooks.json` referenciando caminhos absolutos para os scripts empacotados (sem necessidade de expansão de `${CLAUDE_PLUGIN_ROOT}` no escopo de usuário). Rode o mesmo comando novamente após atualizar o agentmemory para atualizar os caminhos. Entradas de usuário no mesmo arquivo são preservadas; só entradas anteriores do agentmemory são substituídas. + +
+OpenClaw (cole este prompt) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +Guia completo: [`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent (cole este prompt) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +Guia completo: [`integrations/hermes/`](../integrations/hermes/) + +
+ +### Outros agentes + +Inicie o servidor de memória: `npx @agentmemory/agentmemory` + +A entrada do agentmemory é o **mesmo bloco de servidor MCP** em todo host que usa o formato `mcpServers` (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw): + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**Mescle esta entrada no objeto `mcpServers` existente** no arquivo de configuração do host — não substitua o arquivo. Se o arquivo já tem outros servidores, adicione `agentmemory` ao lado deles como outra chave dentro de `mcpServers`. Se `mcpServers` não existe, cole o bloco dentro de `{ "mcpServers": { ... } }`. Os placeholders `${VAR}` herdam `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` do shell no momento em que o servidor MCP sobe — variáveis não definidas passam string vazia e o shim cai para `http://localhost:3111`. Uma entrada cabeada cobre deploys tanto locais quanto remotos (k8s / com reverse-proxy). + +| Agente | Arquivo de configuração | Notas | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | Mescle em `mcpServers`. Deeplink de um clique também disponível no site. | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | Mescle em `mcpServers`. Reinicie o Claude Desktop após editar. | +| **Cline / Roo Code / Kilo Code** | Configurações MCP do Cline (Settings UI → MCP Servers → Edit) | Mesmo bloco `mcpServers`. | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Mesmo bloco `mcpServers`. | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (mescla automaticamente). | +| **OpenClaw** | Configuração MCP do OpenClaw | Mesmo bloco `mcpServers`, ou use o [memory plugin](../integrations/openclaw/) mais profundo. | +| **Codex CLI (somente MCP)** | `.codex/config.toml` | Formato TOML: `codex mcp add agentmemory -- npx -y @agentmemory/mcp`, ou adicione `[mcp_servers.agentmemory]` manualmente. | +| **Codex CLI (plugin completo)** | Marketplace de plugins do Codex | `codex plugin marketplace add rohitg00/agentmemory` e depois `codex plugin add agentmemory@agentmemory`. Registra MCP + 6 hooks de ciclo de vida (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skills. No Codex Desktop, rode também `agentmemory connect codex --with-hooks` até que [openai/codex#16430](https://github.com/openai/codex/issues/16430) seja mergeado — os hooks de plugin estão silenciosos lá. | +| **OpenCode (somente MCP)** | `opencode.json` | Formato diferente — chave `mcp` no topo, comando como array: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. | +| **OpenCode (plugin completo)** | `plugin/opencode/` | 22 hooks de captura automática cobrindo ciclo de vida de sessão, mensagens, tools e erros. Dois comandos slash (`/recall`, `/remember`). Copie `plugin/opencode/` para seu workspace do OpenCode e adicione a entrada do plugin em `opencode.json`. Tabela completa de hooks + análise de gaps em [`plugin/opencode/README.md`](../plugin/opencode/README.md). | +| **pi** | `~/.pi/agent/extensions/agentmemory` | Copie [`integrations/pi`](../integrations/pi/) e reinicie o pi. | +| **Hermes Agent** | `~/.hermes/config.yaml` | Use o [memory provider plugin](../integrations/hermes/) mais profundo com `memory.provider: agentmemory`. | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` escreve o bloco `mcpServers` padrão. O payload dos hooks é compatível em nível de campo com o Claude Code, então os scripts dos 12 hooks existentes funcionam sem modificação — cabê-los na seção `hooks` do mesmo `settings.json`. | +| **Antigravity** (substitui o Gemini CLI) | `mcp_config.json` (no diretório User do Antigravity) | `agentmemory connect antigravity` escreve o bloco `mcpServers` padrão. macOS: `~/Library/Application Support/Antigravity/User/`. Linux: `~/.config/Antigravity/User/`. Use após o sunset do Gemini CLI em 2026-06-18. | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` escreve a configuração no nível do usuário. Overrides por workspace vão em `.kiro/settings/mcp.json` ao lado do seu código. | +| **Goose** | UI de configurações MCP do Goose | Mesmo bloco `mcpServers`. | +| **Aider** | n/a | Fale diretamente com a REST API: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. | +| **Qualquer agente (32+)** | n/a | `npx skillkit install agentmemory` autodetecta o host e mescla. | + +**Clientes MCP em sandbox** (Flatpak / Snap / contêineres restritivos) que não conseguem alcançar o `localhost` do host: também defina `"AGENTMEMORY_FORCE_PROXY": "1"` no bloco `env`, e aponte `AGENTMEMORY_URL` para uma rota que o sandbox realmente alcance (ex: seu IP de LAN). + +### Acesso programático (Python / Rust / Node) + +agentmemory registra suas operações principais como funções iii (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`). Qualquer linguagem com um SDK iii pode chamá-las diretamente via `ws://localhost:49134` — sem um cliente REST separado por linguagem. + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +Exemplo prático: [`examples/python/`](../examples/python/) (quickstart + fluxo de observação/recall). A REST em `:3111` continua disponível para hosts sem runtime iii. + +### A partir do código-fonte + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +Isso inicia o agentmemory com um `iii-engine` local se `iii` já estiver instalado, ou cai para Docker Compose se o Docker estiver disponível. REST, streams e o viewer fazem bind em `127.0.0.1` por padrão. + +Instale o `iii-engine` manualmente. **O agentmemory atualmente fixa o `iii-engine` em `v0.11.2`** — `v0.11.6` introduz um novo modelo que faz sandbox de tudo via `iii worker add` que o agentmemory ainda não foi refatorado para usar. O pin sai assim que o refactor cair. Sobrescreva com `AGENTMEMORY_III_VERSION=` se você migrou manualmente para o modelo de sandbox. + +- **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:** troque `aarch64-apple-darwin` por `x86_64-apple-darwin` +- **Linux x64:** troque por `x86_64-unknown-linux-gnu` +- **Linux arm64:** troque por `aarch64-unknown-linux-gnu` +- **Windows:** baixe `iii-x86_64-pc-windows-msvc.zip` de [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2), extraia `iii.exe`, adicione ao PATH + +Ou use Docker (o `docker-compose.yml` empacotado puxa `iiidev/iii:0.11.2`). Docs completas: [iii.dev/docs](https://iii.dev/docs). + +### Windows + +agentmemory roda em Windows 10/11, mas só o pacote Node.js não é suficiente — você também precisa do runtime `iii-engine` (um binário nativo separado) como processo em segundo plano. O instalador oficial upstream é um script `sh` e hoje não há instalador PowerShell nem pacote scoop/winget, então usuários de Windows têm dois caminhos: + +**Opção A — Binário Windows pré-compilado (recomendado):** + +```powershell +# 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 in your browser +# (we pin to v0.11.2 until agentmemory refactors for the new sandbox +# model that engine v0.11.6+ requires) +# 2. Download iii-x86_64-pc-windows-msvc.zip +# (or iii-aarch64-pc-windows-msvc.zip if you're on an ARM machine) +# 3. Extract iii.exe somewhere on PATH, or place it at: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory checks that location automatically) +# 4. Verify: +iii --version +# Should print: 0.11.2 + +# 5. Then run agentmemory as usual: +npx -y @agentmemory/agentmemory +``` + +**Opção B — Docker Desktop:** + +```powershell +# 1. Install Docker Desktop for Windows +# 2. Start Docker Desktop and make sure the engine is running +# 3. Run agentmemory — it will auto-start the bundled compose file: +npx -y @agentmemory/agentmemory +``` + +**Opção C — apenas MCP standalone (sem engine):** se você só precisa das tools MCP para seu agente e não precisa da REST API, viewer ou cron jobs, pule o engine completamente: + +```powershell +npx -y @agentmemory/agentmemory mcp +# or via the shim package: +npx -y @agentmemory/mcp +``` + +**Diagnóstico para Windows:** se `npx @agentmemory/agentmemory` falhar, rode novamente com `--verbose` para ver o stderr real do engine. Modos de falha comuns: + +| Sintoma | Correção | +|---|---| +| `iii-engine process started` seguido de `did not become ready within 15s` | Engine crashou na inicialização — rode novamente com `--verbose`, verifique stderr | +| `Could not start iii-engine` | Nem `iii.exe` nem Docker estão instalados. Veja Opção A ou B acima | +| Conflito de porta | `netstat -ano \| findstr :3111` para ver o que está em bind, mate o processo ou use `--port ` | +| Fallback do Docker é pulado mesmo com Docker instalado | Confira se o Docker Desktop está de fato rodando (ícone na bandeja do sistema) | + +> Nota: o **engine** iii é um binário pré-compilado, não um crate do cargo — não tente instalá-lo com `cargo install`. (Os **SDKs** do iii são publicados no crates.io, npm e PyPI, mas o agentmemory não precisa deles.) Métodos de instalação do engine suportados, todos fixados em v0.11.2: o binário pré-compilado v0.11.2 acima, o script de instalação `sh` upstream **com a fixação de versão** `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux) e a imagem Docker `iiidev/iii:0.11.2`. Um simples `install.sh | sh` instala o engine **mais recente**, que o agentmemory não suporta — sempre passe `VERSION=0.11.2`. O mais fácil de tudo: basta rodar `npx @agentmemory/agentmemory`, que busca o engine fixado em `~/.agentmemory/bin` para você. + +--- + +

Deploy

+ +Templates de um clique para hosts gerenciados. Cada um inclui um +Dockerfile autocontido que puxa `@agentmemory/agentmemory` do npm +e copia o binário do iii engine da imagem oficial `iiidev/iii` no +Docker Hub — sem necessidade de imagem pré-compilada do agentmemory. +Armazenamento persistente monta em `/data`; o entrypoint de primeiro +boot sobrescreve a configuração iii empacotada pelo npm (que faz +bind em `127.0.0.1`) por uma ajustada para deploy que faz bind em +`0.0.0.0` e usa caminhos absolutos `/data`, gera o segredo HMAC e +depois reduz privilégios de `root` para `node` via `gosu` antes de +fazer exec do CLI do agentmemory. + +

+ Deploy to fly.io + Deploy to Railway +

+ +O botão de deploy de um clique do Render exige um `render.yaml` na raiz do repositório, que mantemos limpo de propósito. Use o fluxo Render Blueprint documentado em [`deploy/render/`](../deploy/render/README.md) para apontar para o blueprint do repo manualmente. + +Detalhes completos de setup (captura de HMAC, túnel SSH do viewer, rotação, backup, mínimos de custo) ficam em [`deploy/`](../deploy/README.md): + +- [`deploy/fly`](../deploy/fly/README.md) — máquina única com `auto_stop_machines = "stop"`; mais barato em idle. +- [`deploy/railway`](../deploy/railway/README.md) — taxa plana do plano Hobby, volume no dashboard. +- [`deploy/render`](../deploy/render/README.md) — fluxo Blueprint, snapshots automáticos de disco nos planos pagos. +- [`deploy/coolify`](../deploy/coolify/README.md) — self-hosted no seu próprio VPS via [Coolify](https://coolify.io/self-hosted); mesma stack Docker Compose, você possui o host e os dados. + +Somente a porta `3111` é publicada. O viewer em `3113` fica em bind no loopback dentro do contêiner — o README de cada template documenta o padrão de túnel SSH para alcançá-lo. + +--- + +

Por que agentmemory

+ +Todo agente de codificação esquece tudo quando a sessão termina. Você desperdiça os primeiros 5 minutos de toda sessão re-explicando sua stack. agentmemory roda em segundo plano e elimina isso por completo. + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### vs memória integrada do agente + +Todo agente de codificação com IA vem com memória integrada — Claude Code tem `MEMORY.md`, Cursor tem notepads, Cline tem memory bank. Funcionam como post-its. agentmemory é o banco de dados pesquisável por trás dos post-its. + +| | Integrada (CLAUDE.md) | agentmemory | +|---|---|---| +| Escala | teto de 200 linhas | Ilimitado | +| Busca | Carrega tudo no contexto | BM25 + vector + graph (só top-K) | +| Custo em tokens | 22K+ com 240 observações | ~1.900 tokens (92% menos) | +| Cross-agent | Arquivos por agente | MCP + REST (qualquer agente) | +| Coordenação | Nenhuma | Leases, signals, actions, routines | +| Observabilidade | Leitura manual de arquivos | Viewer em tempo real em :3113 | + +--- + +

Como funciona

+ +### Pipeline de memória + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### Consolidação de memória em 4 níveis + +Inspirada em como o cérebro humano processa memória — não muito diferente da consolidação do sono. + +| Nível | O quê | Analogia | +|------|------|---------| +| **Working** | Observações brutas a partir do uso de tools | Memória de curto prazo | +| **Episodic** | Resumos de sessão comprimidos | "O que aconteceu" | +| **Semantic** | Fatos e padrões extraídos | "O que eu sei" | +| **Procedural** | Workflows e padrões de decisão | "Como fazer" | + +As memórias decaem com o tempo (curva de Ebbinghaus). Memórias acessadas com frequência se reforçam. Memórias velhas são evictadas automaticamente. Contradições são detectadas e resolvidas. + +### O que é capturado + +| Hook | Captura | +|------|----------| +| `SessionStart` | Caminho do projeto, ID da sessão | +| `UserPromptSubmit` | Prompts do usuário (filtrados por privacidade) | +| `PreToolUse` | Padrões de acesso a arquivos + contexto enriquecido | +| `PostToolUse` | Nome da tool, entrada, saída | +| `PostToolUseFailure` | Contexto do erro | +| `PreCompact` | Reinjeta memória antes da compactação | +| `SubagentStart/Stop` | Ciclo de vida de sub-agentes | +| `Stop` | Resumo de fim de sessão | +| `SessionEnd` | Marcador de sessão completa | + +### Principais capacidades + +| Capacidade | Descrição | +|---|---| +| **Captura automática** | Todo uso de tool registrado via hooks — esforço manual zero | +| **Busca semântica** | BM25 + vector + grafo de conhecimento com fusão RRF | +| **Evolução de memória** | Versionamento, supersessão, grafos de relacionamento | +| **Auto-esquecimento** | Expiração por TTL, detecção de contradição, evicção por importância | +| **Privacy first** | API keys, segredos e tags `` são removidos antes do armazenamento | +| **Self-healing** | Circuit breaker, cadeia de fallback de providers, monitoramento de saúde | +| **Ponte Claude** | Sincronização bidirecional com MEMORY.md | +| **Grafo de conhecimento** | Extração de entidades + travessia BFS | +| **Memória de time** | Compartilhado namespaced + privado entre membros do time | +| **Provenance de citação** | Rastreia qualquer memória de volta às observações originais | +| **Snapshots Git** | Versiona, faz rollback e diff do estado de memória | + +--- + + + +Recuperação triple-stream combinando três sinais: + +| Stream | O que faz | Quando | +|---|---|---| +| **BM25** | Matching de palavra-chave com stemming + expansão de sinônimos | Sempre ativo | +| **Vector** | Similaridade de cosseno sobre embeddings densos | Provider de embedding configurado | +| **Graph** | Travessia do grafo de conhecimento via matching de entidades | Entidades detectadas na query | + +Fundidos com Reciprocal Rank Fusion (RRF, k=60) e diversificados por sessão (máximo de 3 resultados por sessão). + +BM25 tokeniza grego, cirílico, hebraico, árabe e latim acentuado de fábrica. Para memórias em chinês / japonês / coreano, instale os segmentadores opcionais (`npm install @node-rs/jieba tiny-segmenter`) para quebrar runs CJK em tokens em nível de palavra; sem eles, o agentmemory faz soft-fallback para tokenização por run inteiro e imprime uma dica única no stderr. + +### Providers de embedding + +agentmemory autodetecta seu provider. Para melhores resultados, instale embeddings locais (gratuito): + +```bash +npm install @xenova/transformers +``` + +| Provider | Modelo | Custo | Notas | +|---|---|---|---| +| **Local (recomendado)** | `all-MiniLM-L6-v2` | Gratuito | Offline, +8pp de recall sobre BM25-only | +| Gemini | `gemini-embedding-001` | Free tier | 100+ idiomas, 768/1536/3072 dims (MRL), entrada de 2048 tokens. Substitui `text-embedding-004` ([deprecado, encerramento em 14 de janeiro de 2026](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | $0.02/1M | Maior qualidade | +| Voyage AI | `voyage-code-3` | Pago | Otimizado para código | +| Cohere | `embed-english-v3.0` | Trial gratuito | Uso geral | +| OpenRouter | Qualquer modelo | Varia | Proxy multi-modelo | + +--- + +

Servidor MCP

+ +53 tools, 6 resources, 3 prompts e 4 skills — o toolkit MCP de memória mais completo para qualquer agente. + +> **Shim MCP vs servidor completo:** o pacote publicado `@agentmemory/mcp` é um shim fino. Expõe a superfície completa de 51 tools **apenas quando consegue alcançar um servidor agentmemory em execução** via `AGENTMEMORY_URL` (modo proxy). Sem servidor acessível, o shim cai para um set local de 7 tools (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). A variável de ambiente `AGENTMEMORY_TOOLS=core|all` é uma flag *do lado do servidor* — defini-la no bloco `env` do shim não tem efeito. Se você vê só 7 tools no Cursor / OpenCode / Gemini CLI, inicie `npx @agentmemory/agentmemory` (ou a stack Docker) e defina `AGENTMEMORY_URL=http://localhost:3111`. + +### 51 Tools + +
+Tools principais (sempre disponíveis) + +| Tool | Descrição | +|------|-------------| +| `memory_recall` | Busca observações passadas | +| `memory_compress_file` | Comprime arquivos markdown preservando a estrutura | +| `memory_save` | Salva um insight, decisão ou padrão | +| `memory_patterns` | Detecta padrões recorrentes | +| `memory_smart_search` | Busca híbrida semântica + por palavras | +| `memory_file_history` | Observações passadas sobre arquivos específicos | +| `memory_sessions` | Lista sessões recentes | +| `memory_timeline` | Observações cronológicas | +| `memory_profile` | Perfil de projeto (conceitos, arquivos, padrões) | +| `memory_export` | Exporta todos os dados de memória | +| `memory_relations` | Consulta o grafo de relacionamentos | + +
+ +
+Tools estendidas (51 no total — defina AGENTMEMORY_TOOLS=all) + +| Tool | Descrição | +|------|-------------| +| `memory_patterns` | Detecta padrões recorrentes | +| `memory_timeline` | Observações cronológicas | +| `memory_relations` | Consulta o grafo de relacionamentos | +| `memory_graph_query` | Travessia do grafo de conhecimento | +| `memory_consolidate` | Executa a consolidação de 4 níveis | +| `memory_claude_bridge_sync` | Sincroniza com MEMORY.md | +| `memory_team_share` | Compartilha com membros do time | +| `memory_team_feed` | Itens compartilhados recentes | +| `memory_audit` | Trilha de auditoria de operações | +| `memory_governance_delete` | Deleção com trilha de auditoria | +| `memory_snapshot_create` | Snapshot versionado no Git | +| `memory_action_create` | Cria itens de trabalho com dependências | +| `memory_action_update` | Atualiza status de action | +| `memory_frontier` | Actions desbloqueadas ranqueadas por prioridade | +| `memory_next` | A única action mais importante a seguir | +| `memory_lease` | Leases exclusivos de actions (multiagente) | +| `memory_routine_run` | Instancia rotinas de workflow | +| `memory_signal_send` | Mensageria entre agentes | +| `memory_signal_read` | Lê mensagens com confirmação de recebimento | +| `memory_checkpoint` | Portões de condições externas | +| `memory_mesh_sync` | Sincronização P2P entre instâncias | +| `memory_sentinel_create` | Watchers dirigidos por eventos | +| `memory_sentinel_trigger` | Dispara sentinels externamente | +| `memory_sketch_create` | Grafos de actions efêmeros | +| `memory_sketch_promote` | Promove para permanente | +| `memory_crystallize` | Compacta cadeias de actions | +| `memory_diagnose` | Health checks | +| `memory_heal` | Corrige automaticamente estado travado | +| `memory_facet_tag` | Tags dimension:value | +| `memory_facet_query` | Consulta por tags de facet | +| `memory_verify` | Rastreia provenance | + +
+ +### 6 Resources · 3 Prompts · 4 Skills + +| Tipo | Nome | Descrição | +|------|------|-------------| +| Resource | `agentmemory://status` | Saúde, contagem de sessões, contagem de memórias | +| Resource | `agentmemory://project/{name}/profile` | Inteligência por projeto | +| Resource | `agentmemory://memories/latest` | As 10 memórias ativas mais recentes | +| Resource | `agentmemory://graph/stats` | Estatísticas do grafo de conhecimento | +| Prompt | `recall_context` | Busca + retorna mensagens de contexto | +| Prompt | `session_handoff` | Dados de handoff entre agentes | +| Prompt | `detect_patterns` | Analisa padrões recorrentes | +| Skill | `/recall` | Busca na memória | +| Skill | `/remember` | Salva na memória de longo prazo | +| Skill | `/session-history` | Resumos recentes de sessões | +| Skill | `/forget` | Deleta observações/sessões | + +### MCP standalone + +Rode sem o servidor completo — para qualquer cliente MCP. Qualquer um destes funciona: + +```bash +npx -y @agentmemory/agentmemory mcp # canonical (always available) +npx -y @agentmemory/mcp # shim package alias +``` + +Ou adicione à configuração MCP do seu agente: + +A maioria dos agentes (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +Mescle a entrada `agentmemory` no objeto `mcpServers` existente do seu host em vez de substituir o arquivo. Para clientes em sandbox que não conseguem alcançar o `localhost` do host, adicione `"AGENTMEMORY_FORCE_PROXY": "1"` ao bloco env e defina `AGENTMEMORY_URL` para uma rota que o sandbox alcance. + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Copie o arquivo do plugin do repo: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Viewer em tempo real

+ +Sobe automaticamente na porta `3113`. Stream ao vivo de observações, explorador de sessões, navegador de memórias, visualização do grafo de conhecimento e dashboard de saúde. + +```bash +open http://localhost:3113 +``` + +O servidor do viewer faz bind em `127.0.0.1` por padrão. O endpoint servido por REST `/agentmemory/viewer` segue as regras normais de bearer-token `AGENTMEMORY_SECRET`. Os headers CSP usam um nonce de script por resposta e desabilitam atributos de handler inline (`script-src-attr 'none'`). + +--- + +

iii Console

+ +O viewer em `:3113` mostra o que seu agente **lembrou**. O [iii console](https://iii.dev/docs/console) mostra o que seu agente **fez** — cada operação de memória como uma trace do OpenTelemetry, cada entrada KV editável, cada função invocável, cada stream observável. Duas janelas sobre a mesma memória: uma em formato de produto, outra em formato de engine. + +Veja um `memory_smart_search` disparar e enxergue o scan BM25 → busca de embedding → fusão RRF → reranker como um waterfall. Edite um timer de consolidação travado no navegador KV. Reproduza um hook `PostToolUse` com payload ajustado. Fixe o stream WebSocket e veja as observações chegando ao vivo. + +agentmemory oferece isso de graça porque toda função, trigger, escopo de estado e stream é um primitivo iii — nada custom, nada para instrumentar. + +

+ Página Workers do iii console — workers conectados incluindo instâncias do agentmemory com contagem de funções ao vivo e metadados de runtime +
+ Página Workers: todo worker conectado — incluindo o próprio agentmemory — com PID, contagem de funções, runtime e last-seen. +

+ +**Já instalado.** O console vem junto com `iii` — sem instalador separado. + +**Suba junto com o agentmemory:** + +```bash +# agentmemory viewer holds port 3113, so run the console on 3114. +# Engine REST (3111), WebSocket (3112), and bridge (49134) defaults match agentmemory. +iii console --port 3114 +``` + +Depois abra `http://localhost:3114`. Adicione `--enable-flow` para a página experimental de grafo de arquitetura. + +Sobrescreva endpoints do engine apenas se você os tiver movido: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**O que você pode fazer pelo console:** + +| Página | Use para | +|------|-----------| +| **Workers** | Ver todo worker conectado e suas métricas ao vivo — incluindo o próprio worker do agentmemory. | +| **Functions** | Invocar qualquer função do agentmemory diretamente com um payload JSON — útil para testar `memory.recall`, `memory.consolidate`, `graph.query` sem cabear um cliente. | +| **Triggers** | Reproduzir triggers HTTP, cron, event e state — disparar o cron de consolidação manualmente, reexecutar uma rota HTTP, emitir uma mudança de estado. | +| **States** | Navegador KV com CRUD completo — sessões, slots de memória, timers de ciclo de vida, índice de embeddings — edite valores no lugar. | +| **Streams** | Monitor WebSocket ao vivo para escritas de memória, eventos de hook e atualizações de observação à medida que fluem pelos streams iii. | +| **Queues** | Tópicos de fila duráveis + gestão de dead-letter. Reproduza ou descarte jobs de embedding / compressão que falharam. | +| **Traces** | Vistas waterfall / flame / breakdown por serviço do OpenTelemetry. Filtre por `trace_id` para ver exatamente quais funções, chamadas a DB e requisições de embedding um único `memory.search` produziu. | +| **Logs** | Logs OTEL estruturados filtrados e correlacionados a trace/span IDs. | +| **Config** | Configuração de runtime — veja exatamente com quais workers, providers e portas seu engine está rodando. | +| **Flow** | (Opcional, `--enable-flow`) Grafo de arquitetura interativo de todo worker, trigger e stream. | + +

+ Visão waterfall de trace do iii console mostrando duração por span +
+ Traces: waterfall / flame / breakdown por serviço para toda operação de memória. +

+ +**Traces já estão ativos:** + +`iii-config.yaml` vem com o worker `iii-observability` habilitado (`exporter: memory`, `sampling_ratio: 1.0`, métricas + logs). Sem configuração extra — no momento em que o agentmemory inicia, toda operação de memória emite um trace span e um log estruturado que o console consegue ler. + +Se você quiser exportar para Jaeger/Honeycomb/Grafana Tempo, troque `exporter: memory` por `exporter: otlp` e configure o endpoint do collector conforme a documentação de observabilidade do iii. + +> **Aviso:** nenhum auth é aplicado no console em si — mantenha-o em bind em `127.0.0.1` (o padrão) e nunca o exponha publicamente. + +--- + +

Powered by iii

+ +agentmemory **já é uma instância [iii](https://iii.dev) em execução**. Funções, triggers, estado KV, streams, traces OTEL — tudo são primitivos iii. Você não instalou Postgres, Redis, Express, pm2 ou Prometheus, porque o iii os substitui. + +Isso significa que mais um comando estende o agentmemory com uma capacidade totalmente nova. + +### Estenda o agentmemory com um comando + +```bash +iii worker add iii-pubsub # fan memory writes out to every connected instance +iii worker add iii-cron # scheduled consolidation, decay sweeps, snapshot rotation +iii worker add iii-queue # durable retries for embedding + compression jobs +iii worker add iii-observability # OTEL traces on every memory op (default on) +iii worker add iii-sandbox # run recalled code inside an isolated microVM +iii worker add iii-database # swap in a SQL-backed state adapter +iii worker add mcp # generic MCP host alongside the agentmemory MCP +``` + +Cada `iii worker add` registra novas funções e triggers no mesmo engine onde o agentmemory já está rodando. O viewer e o console os reconhecem na hora — sem reload, sem nova integração, sem novo contêiner. + +| `iii worker add` | O que você ganha em cima do agentmemory | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | Memória multi-instância: todo `remember` faz fanout, todo `search` lê a união | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Ciclo de vida agendado — consolidação noturna, snapshots semanais, decaimento em relógio fixo | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | Retries duráveis: jobs falhos de embedding + compressão sobrevivem a restart, sem observações perdidas | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | Traces OTEL, métricas, logs em toda função — cabeado em `iii-config.yaml` desde o primeiro dia | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | Código que veio do `memory_recall` roda dentro de uma VM descartável, não no seu shell | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | Adaptador de estado baseado em SQL quando você ultrapassa o KV in-memory padrão | +| [`mcp`](https://workers.iii.dev/workers/mcp) | Suba servidores MCP adicionais ao lado do MCP do agentmemory, compartilhando o mesmo engine | + +Registry completo: [workers.iii.dev](https://workers.iii.dev). Todo worker lá se compõe pelos mesmos primitivos que o agentmemory usa — e o agentmemory que você já tem é um deles. + +### O que o iii substitui + +| Stack tradicional | agentmemory usa | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + índice vetorial in-memory | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | Supervisão de workers do iii engine | +| Prometheus / Grafana | iii OTEL + monitor de saúde | +| Sistemas de plugin customizados | `iii worker add ` | + +**118 arquivos de código · ~21.800 LOC · 950+ tests · 123 funções · 34 escopos KV** — tudo em cima de três primitivos. Sem `agentmemory plugin install`. O sistema de plugins é o próprio iii. + +--- + +

Configuração

+ +### Providers de LLM + +agentmemory autodetecta a partir do seu ambiente. Por padrão, nenhuma chamada LLM é feita a menos que você configure um provider ou opte explicitamente pelo fallback de assinatura do Claude. + +| Provider | Config | Notas | +|----------|--------|-------| +| **No-op (padrão)** | Sem configuração | Compress/summarize via LLM DESATIVADO. Compressão sintética BM25 + recall ainda funcionam. Veja `AGENTMEMORY_ALLOW_AGENT_SDK` abaixo se você dependia do fallback de assinatura do Claude. | +| Anthropic API | `ANTHROPIC_API_KEY` | Cobrança por token | +| MiniMax | `MINIMAX_API_KEY` | Compatível com Anthropic | +| Gemini | `GEMINI_API_KEY` | Também habilita embeddings | +| OpenRouter | `OPENROUTER_API_KEY` | Qualquer modelo | +| Claude subscription fallback | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Apenas opt-in. Cria sessões de `@anthropic-ai/claude-agent-sdk` — costumava causar recursão sem limite no Stop-hook, por isso não é mais o padrão. | + +### Seleção de modelo com consciência de custo + +A compressão em background roda em toda observação, então a escolha de modelo muda o gasto mensal de forma significativa. Dados de workload capturados: 635 requisições / 888K tokens / 35 horas de uso ativo, executados contra três modelos OpenRouter a preços de 2026-05-23. + +| Tier | Modelo | Input / 1M | Output / 1M | Custo para as 35h capturadas | Notas | +|------|-------|------------|-------------|---------------------------|-------| +| Recomendado | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | Qualidade sólida de compressão + sumarização a um custo ~10× menor que o Sonnet. | +| Recomendado | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | Mais antigo mas ainda OK para workloads só de compressão. | +| Recomendado | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | Bom raciocínio de código se suas sessões forem muito orientadas a código. | +| Premium | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | Alta qualidade, mas caro para trabalho de background sempre ativo. | +| Premium | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | Tier similar ao Sonnet. | +| Evitar | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | Modelo classe reasoning; gasto desproporcional para compressão. | + +agentmemory imprime um aviso em runtime quando `OPENROUTER_MODEL` casa com um padrão de tier premium. Defina `AGENTMEMORY_SUPPRESS_COST_WARNING=1` para silenciar depois que você tiver tomado uma decisão informada. + +Trade-off qualidade vs custo para trabalho de memória: compressão é uma tarefa de sumarização com critério de qualidade relativamente frouxo (quem relê o resumo é o agente, não o usuário). DeepSeek-V4-Pro / Qwen3-Coder ficam dentro do erro de arredondamento do Sonnet nessa tarefa, custando ~10× menos. Reserve os modelos tier premium para as queries que você lê diretamente. + +Fontes: [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/). + +### Memória multiagente (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +Em setups multiagente onde vários papéis compartilham um servidor agentmemory (architect / developer / reviewer / researcher / support-agent), `AGENT_ID` etiqueta cada escrita com o papel que a fez. `AGENTMEMORY_AGENT_SCOPE` controla se o recall filtra por essa tag. + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" +``` + +Dois modos: + +| Modo | Etiqueta escritas | Filtra recall | Quando usar | +|------|------------|---------------|-------------| +| `shared` (padrão) | sim | não | Contexto cross-agent com trilha de auditoria. O architect pode ver o que o developer anotou, mas toda linha registra quem disse. | +| `isolated` | sim | sim | Separação estrita. O architect nunca vê observações / memórias / sessões do developer. | + +O que é etiquetado quando `AGENT_ID` está definido: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. O papel flui `api::session::start` → `mem::observe` → `mem::compress` → KV. + +O que é filtrado no modo isolated: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Cada endpoint aceita `?agentId=` para sobrescrever por requisição, e `?agentId=*` para sair do escopo do env por completo. `/memories` também aceita `?includeOrphans=true` para mostrar memórias pré-AGENT_ID cujo `agentId` é undefined. + +Override por chamada na camada SDK / REST: todo endpoint mutador (`/session/start`, `/remember`) aceita um campo `agentId` no body da requisição que vence o env. Útil para runtimes que roteiam muitos papéis por um único processo de servidor. + +Quando `AGENT_ID` não está definido, a memória permanece sem escopo (comportamento legado, sem tags, sem filtros). + +### Portas + +agentmemory + iii-engine fazem bind em quatro portas por padrão. Se um restart falhar com `port in use`, esta tabela diz qual processo procurar. + +| Porta | Processo | Propósito | Override por env | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | Worker de streams interno (consumido por agentmemory + viewer) | `III_STREAMS_PORT` | +| `3113` | agentmemory | Viewer em tempo real (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — workers se registram aqui, telemetria OTel flui por cima | `III_ENGINE_URL` (URL completa, padrão `ws://localhost:49134`) | + +Limpeza de processo travado quando as portas ficam ocupadas após uma execução crashada: + +```bash +# macOS / Linux — find whatever is on each port and kill it +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` recolhe tanto o worker quanto o pidfile do engine de forma limpa no shutdown graceful. A limpeza manual acima só serve para o caso pós-crash em que nenhum pidfile foi deixado para trás. + +### Arquivo de configuração + +Coloque a configuração de runtime do agentmemory em `~/.agentmemory/.env` em vez de exportar variáveis em cada shell. Se o viewer mostrar uma dica de setup como `export ANTHROPIC_API_KEY=...`, copie para este arquivo como `ANTHROPIC_API_KEY=...` sem o prefixo `export`, depois reinicie o agentmemory. + +Variáveis de ambiente do processo continuam funcionando e têm precedência sobre os valores no arquivo. + +No Windows, o mesmo arquivo fica em `%USERPROFILE%\.agentmemory\.env`: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +Para testar com uma assinatura Claude Code Pro/Max em vez de uma API key, faça opt-in explícito: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +Ligue features de graph ou consolidation no mesmo arquivo se quiser: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### Variáveis de ambiente + +Crie `~/.agentmemory/.env`: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +124 endpoints na porta `3111`. A REST API faz bind em `127.0.0.1` por padrão. Endpoints protegidos exigem `Authorization: Bearer ` quando `AGENTMEMORY_SECRET` está definido, e endpoints de mesh sync exigem `AGENTMEMORY_SECRET` em ambos os peers. + +
+Endpoints principais + +| Method | Path | Descrição | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | Health check (sempre público) | +| `POST` | `/agentmemory/session/start` | Inicia sessão + obtém contexto | +| `POST` | `/agentmemory/session/end` | Encerra sessão | +| `POST` | `/agentmemory/observe` | Captura observação | +| `POST` | `/agentmemory/smart-search` | Busca híbrida | +| `POST` | `/agentmemory/context` | Gera contexto | +| `POST` | `/agentmemory/remember` | Salva na memória de longo prazo | +| `POST` | `/agentmemory/forget` | Deleta observações | +| `POST` | `/agentmemory/enrich` | Contexto de arquivo + memórias + bugs | +| `GET` | `/agentmemory/profile` | Perfil de projeto | +| `GET` | `/agentmemory/export` | Exporta todos os dados | +| `POST` | `/agentmemory/import` | Importa de JSON | +| `POST` | `/agentmemory/graph/query` | Query do grafo de conhecimento | +| `POST` | `/agentmemory/team/share` | Compartilha com o time | +| `GET` | `/agentmemory/audit` | Trilha de auditoria | + +Lista completa de endpoints: [`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Desenvolvimento

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 950+ tests +npm run test:integration # API tests (requires running services) +``` + +**Pré-requisitos:** Node.js >= 20, [iii-engine](https://iii.dev/docs) ou Docker + +

Licença

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.ru-RU.md b/READMEs/README.ru-RU.md new file mode 100644 index 0000000..804f380 --- /dev/null +++ b/READMEs/README.ru-RU.md @@ -0,0 +1,1376 @@ +

+ agentmemory — Постоянная память для ИИ-агентов программирования +

+ +

+ + Ваш агент программирования помнит всё. Больше не нужно объяснять заново. + Built on iii engine +
+ Постоянная память для Claude Code, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode и любого MCP-клиента. +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Документ проекта: 1200 звёзд / 172 форка в гисте +

+ +

+ Этот gist расширяет шаблон LLM Wiki от Karpathy: confidence-оценкой, жизненным циклом, графами знаний и гибридным поиском — agentmemory является его реализацией. +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ Демо agentmemory +

+ +

+ Установка • + Быстрый старт • + Бенчмарки • + Сравнение • + Агенты • + Как это работает • + MCP • + Просмотрщик • + iii Console • + Powered by iii • + Конфигурация • + API +

+ +--- + +## Install + +```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 connect claude-code # wire your agent (also: codex, cursor, gemini-cli, ...) +``` + +Или через `npx` (без установки): + +```bash +npx @agentmemory/agentmemory +``` + +Внимание: npx кеширует пакеты по версиям. Если простой `npx @agentmemory/agentmemory` выдаёт более старый релиз, принудительно возьмите свежий через `npx -y @agentmemory/agentmemory@latest` или однократно очистите кеш: `rm -rf ~/.npm/_npx` (macOS/Linux; на Windows удалите `%LOCALAPPDATA%\npm-cache\_npx`). Начиная с v0.9.16+, при первом запуске npx предлагает поставить пакет глобально прямо в строке — после этого простая команда `agentmemory` будет работать повсюду. + +Полный список опций — в разделе [Быстрый старт](#quick-start) ниже. Привязка конкретного агента — в разделе [Работает с каждым агентом](#works-with-every-agent). + +--- + +

Работает с каждым агентом

+ +agentmemory работает с любым агентом, поддерживающим хуки, MCP или REST API. Все агенты используют один и тот же сервер памяти. + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+нативный плагин + 12 хуков + MCP +
+Codex CLI
+Codex CLI
+нативный плагин + 6 хуков + MCP +
+OpenClaw
+OpenClaw
+нативный плагин + MCP +
+Hermes
+Hermes
+нативный плагин + MCP +
+pi
+pi
+нативный плагин + MCP +
+OpenHuman
+OpenHuman
+нативный бэкенд трейта Memory +
+Cursor
+Cursor
+MCP-сервер +
+Gemini CLI
+Gemini CLI
+MCP-сервер +
+OpenCode
+OpenCode
+22 хука + MCP + плагин +
+Cline
+Cline
+MCP-сервер +
+Goose
+Goose
+MCP-сервер +
+Kilo Code
+Kilo Code
+MCP-сервер +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP-сервер +
+Windsurf
+Windsurf
+MCP-сервер +
+Roo Code
+Roo Code
+MCP-сервер +
+ +

+ Работает с любым агентом, который говорит на MCP или HTTP. Один сервер — общая память для всех. +

+ +--- + +Вы заново объясняете архитектуру в каждой сессии. Вы заново находите те же баги. Вы заново обучаете агента тем же предпочтениям. Встроенная память (CLAUDE.md, .cursorrules) упирается в 200 строк и устаревает. agentmemory это решает. Он тихо собирает то, что делает ваш агент, сжимает это в индексируемую память и подмешивает нужный контекст при старте следующей сессии. Одна команда. Работает между агентами. + +**Что меняется:** В сессии 1 вы настраиваете JWT-аутентификацию. В сессии 2 просите добавить rate limiting. Агент уже знает, что аутентификация использует middleware jose в `src/middleware/auth.ts`, что ваши тесты покрывают валидацию токенов, и что вы выбрали jose, а не jsonwebtoken, из-за совместимости с Edge. Никаких повторных объяснений. Никакого копирования-вставки. Агент просто *знает*. + +```bash +npx @agentmemory/agentmemory +``` + +> **Новое в v0.9.0** — Лендинг по адресу [agent-memory.dev](https://agent-memory.dev), коннектор файловой системы (`@agentmemory/fs-watcher`), автономный MCP теперь проксирует к работающему серверу, поэтому хуки и просмотрщик согласованы, политика аудита кодифицирована для каждого пути удаления, проверка состояния больше не помечает `memory_critical` на маленьких Node-процессах. Полные заметки в [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18). + +--- + +

Бенчмарки

+ + + + + + +
+ +### Точность извлечения + +**coding-agent-life-v1** (внутренний корпус, воспроизводимо в sandbox) + +| Адаптер | P@5 | R@5 | Top-5 hit rate | p50-задержка | +|---|---|---|---|---| +| **agentmemory hybrid** | **0.578** | **0.967** | **15 / 15** | 14 мс | +| Базовый grep | 0.267 | 0.967 | 15 / 15 | 0 мс | + +100 % попаданий в top-5. **2,2×** выше точность, чем у grep-базы, на тех же входах. Полная разбивка по типам: [`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 вопросов) + +| Система | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| Fallback только BM25 | 86.2% | 94.6% | 71.5% | + + + +### Экономия токенов + +| Подход | Токенов в год | Стоимость в год | +|---|---|---| +| Вставлять весь контекст | 19,5М+ | Невозможно (выходит за окно) | +| LLM-резюме | ~650K | ~500 $ | +| **agentmemory** | **~170K** | **~10 $** | +| agentmemory + локальные эмбеддинги | ~170K | **0 $** | + +
+ +> Модель эмбеддингов: `all-MiniLM-L6-v2` (локальная, бесплатная, без API-ключа). Полные отчёты: [`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](../benchmark/QUALITY.md), [`benchmark/SCALE.md`](../benchmark/SCALE.md). Сравнение с конкурентами: [`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory против mem0, Letta, Khoj, claude-mem, Hippo. + +**Воспроизведите локально:** [`eval/README.md`](../eval/README.md) — harness с подключаемыми адаптерами для LongMemEval `_s` (публичный, 500 вопросов) и `coding-agent-life-v1` (внутренний корпус из 15 сессий). Адаптеры grep / vector / agentmemory сравниваются бок о бок, вывод NDJSON, опубликованные scorecard'ы попадают в [`docs/benchmarks/`](../docs/benchmarks/). + +**Хорошо сочетается с [codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything) и [Graphify](https://github.com/safishamsi/graphify).** Индексация кодового графа, мультиагентные конвейеры сборки и более широкие графы знаний по докам / PDF / изображениям / видео. agentmemory запоминает работу; эти три проекта подсвечивают остальное в слое контекста. Рецепты и таблица маршрутизации вопросов: [`docs/recipes/pairings.md`](../docs/recipes/pairings.md). + +--- + +

Сравнение с конкурентами

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)Встроенное (CLAUDE.md)
ТипДвижок памяти + MCP-серверAPI уровня памятиПолноценный агентский runtimeСтатический файл
R@5 при извлечении95.2%68.5% (LoCoMo)83.2% (LoCoMo)Н/Д (grep)
Авто-захват12 хуков (никаких ручных усилий)Ручные вызовы add()Агент сам редактируетРучное редактирование
ПоискBM25 + векторный + граф (RRF-слияние)Векторный + графВекторный (архивный)Загружает всё в контекст
МультиагентностьMCP + REST + lease'ы + сигналыAPI (без координации)Только внутри runtime LettaОтдельные файлы на агента
Привязка к фреймворкуНет (любой MCP-клиент)НетВысокая (нужен Letta)Формат на агента
Внешние зависимостиНет (SQLite + iii-engine)Qdrant / pgvectorPostgres + векторная БДНет
Жизненный цикл памяти4-уровневая консолидация + затухание + авто-забываниеПассивное извлечениеУправляется агентомРучное усечение
Эффективность по токенам~1 900 токенов/сессия (10 $/год)Зависит от интеграцииCore memory в контексте22K+ токенов при 240 наблюдениях
Просмотрщик в реальном времениДа (порт 3113)Облачная панельОблачная панельНет
Self-hostedДа (по умолчанию)ОпциональноОпциональноДа
+ +--- + +

Быстрый старт

+ +Совместимость: этот релиз нацелен на стабильный `iii-sdk` `^0.11.0` и iii-engine v0.11.x. + +### Попробуйте за 30 секунд + +```bash +# Terminal 1: start the server +npx @agentmemory/agentmemory + +# Terminal 2: seed sample data and see recall in action +npx @agentmemory/agentmemory demo +``` + +`demo` заполняет 3 реалистичные сессии (JWT-аутентификация, исправление N+1-запроса, rate limiting) и запускает по ним семантический поиск. Вы увидите, как находится «N+1 query fix», когда вы ищете «database performance optimization» — keyword-сопоставление так не умеет. + +Откройте `http://localhost:3113`, чтобы видеть построение памяти в реальном времени. + +### Рекомендуется: глобальная установка + +`npx` кеширует пакеты по версиям. Если на прошлой неделе вы запускали `npx @agentmemory/agentmemory@0.9.14`, простой `npx @agentmemory/agentmemory` может выдать застаревшую 0.9.14 из `~/.npm/_npx/`, а не последний релиз. Установите один раз — и команда `agentmemory` будет работать везде: + +```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 stop # tear it down +agentmemory remove # uninstall everything we created +agentmemory connect claude-code # wire one agent +agentmemory doctor # interactive diagnostics + fix prompts +``` + +Начиная с v0.9.16, первый запуск npx предлагает установку глобально в той же строке — ответьте `Y` один раз, и готово. Если вы пропустили шаг, воспользуйтесь любым из этих вариантов для свежего скачивания: + +```bash +npx -y @agentmemory/agentmemory@latest # forces latest from npm (cross-platform) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # macOS/Linux only (POSIX shell) +``` + +В Windows / PowerShell эквивалент очистки кеша — `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"`, а вариант выше `npx -y ...@latest` остаётся кросс-платформенным. + +### Воспроизведение сессий + +Каждую сессию, которую записывает agentmemory, можно воспроизвести. Откройте просмотрщик, выберите вкладку **Replay** и пролистывайте таймлайн: промпты, вызовы инструментов, результаты вызовов и ответы отображаются как отдельные события с play/pause, регулировкой скорости (0,5×–4×) и горячими клавишами (пробел переключает, стрелки — пошаговое перемещение). + +Уже есть старые JSONL-расшифровки Claude Code, которые хотите подгрузить? + +```bash +# Import everything under the default ~/.claude/projects +npx @agentmemory/agentmemory import-jsonl + +# Or import a single file +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +Импортированные сессии появятся в Replay-пикере рядом с нативными. Под капотом каждая запись проходит через iii-функции `mem::replay::load`, `mem::replay::sessions` и `mem::replay::import-jsonl` — никаких побочных серверов. + +### Обновление / Обслуживание + +Используйте команду обслуживания, когда специально хотите обновить локальный runtime: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +Внимание: команда меняет текущее рабочее окружение / runtime. Она может обновлять JavaScript-зависимости и стянуть закреплённый Docker-образ `iiidev/iii:0.11.2`. Она никогда не устанавливает незакреплённый или более новый движок iii. + +Детали реализации — в `src/cli.ts` (см. `runUpgrade` в районе `src/cli.ts:544-595`). + +### Claude Code (один блок, вставьте его) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Claude Code без установки плагина (путь MCP-standalone) + +Если подключать MCP-сервер agentmemory через `~/.claude.json` напрямую, минуя `/plugin install`, Claude Code никогда не разрешит `${CLAUDE_PLUGIN_ROOT}`, и в `~/.claude/settings.json` придётся прописывать абсолютные пути к скриптам хуков. Эти пути обычно включают версию agentmemory (например, `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), так что следующее обновление тихо ломает каждый хук. + +Обходное решение: + +```bash +agentmemory connect claude-code --with-hooks +``` + +Это вливает те же команды хуков в `~/.claude/settings.json` с абсолютными путями, разрешёнными в каталог `plugin/` текущего установленного пакета `@agentmemory/agentmemory`. После обновления agentmemory запустите команду ещё раз, чтобы освежить пути. Записи пользователя в этом файле сохраняются; заменяются только предыдущие записи agentmemory. Рекомендуемым способом остаётся путь через `/plugin install`. +Для удалённых или защищённых развертываний запускайте Claude Code с заданными `AGENTMEMORY_URL` и `AGENTMEMORY_SECRET`. Плагин пробрасывает обе переменные во встроенный MCP-сервер; если `AGENTMEMORY_URL` пуст, MCP-shim использует `http://localhost:3111`. + +### Codex CLI (платформа плагинов Codex) + +```bash +# 1. start the memory server in a separate terminal +npx @agentmemory/agentmemory + +# 2. register the agentmemory marketplace and install the plugin +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Плагин Codex поставляется из того же каталога `plugin/`, что и плагин Claude Code. Он регистрирует: + +- `@agentmemory/mcp` как MCP-сервер (проксирует все 51 инструмент, когда `AGENTMEMORY_URL` указывает на работающий сервер agentmemory; локально откатывается к 7 инструментам, если сервер недоступен) +- 6 хуков жизненного цикла: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 4 skill'а: `/recall`, `/remember`, `/session-history`, `/forget` + +Хук-движок Codex подставляет `CLAUDE_PLUGIN_ROOT` в подпроцессы хуков (см. [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)), поэтому одни и те же скрипты хуков работают на обоих хостах без дублирования. События Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure доступны только в Claude Code и для Codex не регистрируются. + +#### Codex Desktop: хуки плагинов сейчас тихие (есть обходное решение) + +`CodexHooks` и `PluginHooks` оба стабильны и включены по умолчанию в [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs), но текущие сборки Codex Desktop не диспатчат локальный `hooks.json` плагина ([openai/codex#16430](https://github.com/openai/codex/issues/16430)). Инструменты MCP по-прежнему работают; не хватает только наблюдений жизненного цикла. + +Пока upstream не подвезёт фикс, продублируйте те же команды хуков в глобальный `~/.codex/hooks.json`: + +```bash +agentmemory connect codex --with-hooks +``` + +Это добавляет идемпотентный блок в `~/.codex/hooks.json` со ссылками на абсолютные пути к встроенным скриптам (раскрывать `${CLAUDE_PLUGIN_ROOT}` на уровне пользователя не нужно). После обновления agentmemory запустите ту же команду ещё раз, чтобы освежить пути. Записи пользователя в этом файле сохраняются; заменяются только предыдущие записи agentmemory. + +
+OpenClaw (вставьте этот промпт) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +Полное руководство: [`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent (вставьте этот промпт) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +Полное руководство: [`integrations/hermes/`](../integrations/hermes/) + +
+ +### Другие агенты + +Запустите сервер памяти: `npx @agentmemory/agentmemory` + +Запись agentmemory — это **один и тот же блок MCP-сервера** для всех хостов, использующих формат `mcpServers` (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw): + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**Вставьте эту запись в существующий объект `mcpServers`** в файле конфигурации хоста — не заменяйте сам файл. Если там уже есть другие серверы, добавьте `agentmemory` рядом с ними как новый ключ внутри `mcpServers`. Если `mcpServers` отсутствует совсем, вставьте блок внутрь `{ "mcpServers": { ... } }`. Подстановки `${VAR}` наследуют `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` из shell в момент запуска MCP-сервера — незаданные переменные передаются пустыми, и shim откатывается на `http://localhost:3111`. Одна подключённая запись покрывает как локальные, так и удалённые (k8s / reverse-proxied) развертывания. + +| Агент | Файл конфигурации | Заметки | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | Добавить в `mcpServers`. Также доступен deeplink в один клик на сайте. | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | Добавить в `mcpServers`. После правки перезапустить Claude Desktop. | +| **Cline / Roo Code / Kilo Code** | Настройки MCP в Cline (Settings UI → MCP Servers → Edit) | Тот же блок `mcpServers`. | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Тот же блок `mcpServers`. | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (автоматическое слияние). | +| **OpenClaw** | MCP-конфиг OpenClaw | Тот же блок `mcpServers`, либо более глубокий [memory-плагин](../integrations/openclaw/). | +| **Codex CLI (только MCP)** | `.codex/config.toml` | Формат TOML: `codex mcp add agentmemory -- npx -y @agentmemory/mcp`, либо добавьте `[mcp_servers.agentmemory]` вручную. | +| **Codex CLI (полный плагин)** | Маркетплейс плагинов Codex | `codex plugin marketplace add rohitg00/agentmemory`, затем `codex plugin add agentmemory@agentmemory`. Регистрирует MCP + 6 хуков жизненного цикла (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skill'а. На Codex Desktop дополнительно запустите `agentmemory connect codex --with-hooks`, пока не зарелизят [openai/codex#16430](https://github.com/openai/codex/issues/16430) — хуки плагина там пока тихие. | +| **OpenCode (только MCP)** | `opencode.json` | Другая форма — корневой ключ `mcp`, команда задаётся массивом: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. | +| **OpenCode (полный плагин)** | `plugin/opencode/` | 22 хука авто-захвата по жизненному циклу сессии, сообщениям, инструментам и ошибкам. Две slash-команды (`/recall`, `/remember`). Скопируйте `plugin/opencode/` в свой рабочий каталог OpenCode и добавьте запись плагина в `opencode.json`. Полная таблица хуков и анализ пробелов — в [`plugin/opencode/README.md`](../plugin/opencode/README.md). | +| **pi** | `~/.pi/agent/extensions/agentmemory` | Скопируйте [`integrations/pi`](../integrations/pi/) и перезапустите pi. | +| **Hermes Agent** | `~/.hermes/config.yaml` | Используйте более глубокий [плагин провайдера памяти](../integrations/hermes/) с `memory.provider: agentmemory`. | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` записывает стандартный блок `mcpServers`. Payload хуков по полям совместим с Claude Code, поэтому существующие 12 скриптов хуков работают без изменений — подключите их через секцию `hooks` в том же `settings.json`. | +| **Antigravity** (заменяет Gemini CLI) | `mcp_config.json` (в каталоге User у Antigravity) | `agentmemory connect antigravity` записывает стандартный блок `mcpServers`. macOS: `~/Library/Application Support/Antigravity/User/`. Linux: `~/.config/Antigravity/User/`. Использовать после отключения Gemini CLI 2026-06-18. | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` записывает конфиг на уровне пользователя. Workspace-переопределения — в `.kiro/settings/mcp.json` рядом с кодом. | +| **Goose** | UI настроек MCP в Goose | Тот же блок `mcpServers`. | +| **Aider** | н/д | Разговаривайте напрямую с REST API: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. | +| **Любой агент (32+)** | н/д | `npx skillkit install agentmemory` сам определит хост и сольёт настройки. | + +**MCP-клиенты в sandbox** (Flatpak / Snap / ограничивающие контейнеры), которые не могут добраться до `localhost` хоста: дополнительно установите `"AGENTMEMORY_FORCE_PROXY": "1"` в блоке `env` и укажите `AGENTMEMORY_URL` на маршрут, до которого sandbox действительно может дотянуться (например, IP в локальной сети). + +### Программный доступ (Python / Rust / Node) + +agentmemory регистрирует свои основные операции как iii-функции (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`). Любой язык с SDK для iii может вызывать их напрямую через `ws://localhost:49134` — отдельный REST-клиент на каждый язык не требуется. + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +Рабочий пример: [`examples/python/`](../examples/python/) (быстрый старт + поток наблюдения/извлечения). REST на `:3111` остаётся доступным для хостов без iii-runtime. + +### Из исходников + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +Это поднимает agentmemory с локальным `iii-engine`, если `iii` уже установлен, либо откатывается к Docker Compose, если есть Docker. REST, стримы и просмотрщик по умолчанию слушают на `127.0.0.1`. + +Установите `iii-engine` вручную. **agentmemory сейчас зафиксирован на `iii-engine` `v0.11.2`** — `v0.11.6` вводит новую модель «всё через `iii worker add` в sandbox», под которую agentmemory ещё не отрефакторен. Закрепление снимется, как только рефакторинг будет завершён. Переопределите через `AGENTMEMORY_III_VERSION=`, если вы вручную перешли на sandbox-модель. + +- **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:** замените `aarch64-apple-darwin` на `x86_64-apple-darwin` +- **Linux x64:** замените на `x86_64-unknown-linux-gnu` +- **Linux arm64:** замените на `aarch64-unknown-linux-gnu` +- **Windows:** скачайте `iii-x86_64-pc-windows-msvc.zip` из [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2), распакуйте `iii.exe`, добавьте в PATH + +Либо используйте Docker (входящий в комплект `docker-compose.yml` тянет `iiidev/iii:0.11.2`). Полная документация: [iii.dev/docs](https://iii.dev/docs). + +### Windows + +agentmemory работает на Windows 10/11, но одного Node.js-пакета мало — также нужен runtime `iii-engine` (отдельный нативный бинарь) как фоновый процесс. Официальный upstream-установщик — это `sh`-скрипт, на сегодня нет ни PowerShell-установщика, ни пакета scoop/winget, поэтому у пользователей Windows два пути: + +**Вариант A — Готовый Windows-бинарь (рекомендуется):** + +```powershell +# 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 in your browser +# (we pin to v0.11.2 until agentmemory refactors for the new sandbox +# model that engine v0.11.6+ requires) +# 2. Download iii-x86_64-pc-windows-msvc.zip +# (or iii-aarch64-pc-windows-msvc.zip if you're on an ARM machine) +# 3. Extract iii.exe somewhere on PATH, or place it at: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory checks that location automatically) +# 4. Verify: +iii --version +# Should print: 0.11.2 + +# 5. Then run agentmemory as usual: +npx -y @agentmemory/agentmemory +``` + +**Вариант B — Docker Desktop:** + +```powershell +# 1. Install Docker Desktop for Windows +# 2. Start Docker Desktop and make sure the engine is running +# 3. Run agentmemory — it will auto-start the bundled compose file: +npx -y @agentmemory/agentmemory +``` + +**Вариант C — только standalone MCP (без движка):** если вам нужны только MCP-инструменты для агента и не нужны REST API, просмотрщик или cron-задачи, пропустите движок целиком: + +```powershell +npx -y @agentmemory/agentmemory mcp +# or via the shim package: +npx -y @agentmemory/mcp +``` + +**Диагностика на Windows:** если `npx @agentmemory/agentmemory` падает, перезапустите с `--verbose`, чтобы увидеть реальный stderr движка. Частые сценарии сбоя: + +| Симптом | Что делать | +|---|---| +| `iii-engine process started`, затем `did not become ready within 15s` | Движок упал при старте — перезапустите с `--verbose`, проверьте stderr | +| `Could not start iii-engine` | Не установлены ни `iii.exe`, ни Docker. См. варианты A или B выше | +| Конфликт порта | `netstat -ano \| findstr :3111`, чтобы понять, что занимает порт, затем убить процесс или использовать `--port ` | +| Откат на Docker пропускается, хотя Docker установлен | Убедитесь, что Docker Desktop действительно запущен (иконка в трее) | + +> Примечание: **движок** iii — это готовый бинарь, а не cargo-крейт, не пытайтесь установить его через `cargo install`. (**SDK** iii опубликованы на crates.io, npm и PyPI, но agentmemory они не нужны.) Поддерживаемые способы установки движка, все закреплены на v0.11.2: готовый бинарь v0.11.2 выше, upstream-`sh`-скрипт **с закреплением версии** `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux) и Docker-образ `iiidev/iii:0.11.2`. Простой `install.sh | sh` устанавливает **последний** движок, который agentmemory не поддерживает, — всегда передавайте `VERSION=0.11.2`. Самый простой вариант: просто запустите `npx @agentmemory/agentmemory`, который сам загрузит закреплённый движок в `~/.agentmemory/bin`. + +--- + +

Развёртывание

+ +Шаблоны в один клик для managed-хостов. Каждый поставляет автономный +Dockerfile, который тянет `@agentmemory/agentmemory` из npm и копирует +бинарь iii engine из официального образа `iiidev/iii` на Docker +Hub — собственный преcобранный образ agentmemory не нужен. Постоянное +хранилище монтируется в `/data`; entrypoint при первом запуске +перезаписывает поставляемый npm'ом iii-конфиг (который слушает на +`127.0.0.1`) на deploy-вариант, слушающий на `0.0.0.0` и +использующий абсолютные пути `/data`, генерирует HMAC-секрет, +а затем понижает привилегии с `root` до `node` через `gosu` +перед запуском CLI agentmemory. + +

+ Deploy to fly.io + Deploy to Railway +

+ +Кнопке Render «деплой в один клик» нужен `render.yaml` в корне репозитория, который мы намеренно держим чистым. Используйте схему через Render Blueprint, описанную в [`deploy/render/`](./deploy/render/README.md), чтобы вручную указать на in-repo blueprint. + +Полные детали настройки (захват HMAC, SSH-туннель к просмотрщику, ротация, бэкап, нижние пороги стоимости) — в [`deploy/`](./deploy/README.md): + +- [`deploy/fly`](./deploy/fly/README.md) — одна машина с + `auto_stop_machines = "stop"`; дешевле всего в простое. +- [`deploy/railway`](./deploy/railway/README.md) — фиксированный тариф Hobby, + том в панели. +- [`deploy/render`](./deploy/render/README.md) — поток Blueprint, + автоматические снапшоты диска на платных тарифах. +- [`deploy/coolify`](./deploy/coolify/README.md) — self-hosted на собственном + VPS через [Coolify](https://coolify.io/self-hosted); тот же Docker + Compose-стек, хост и данные у вас. + +Публикуется только порт `3111`. Просмотрщик на `3113` остаётся +привязанным к loopback внутри контейнера — в README каждого шаблона +описан паттерн SSH-туннеля, чтобы до него достучаться. + +--- + +

Зачем agentmemory

+ +Каждый агент программирования забывает всё, когда сессия заканчивается. Вы тратите первые 5 минут каждой сессии на повторное объяснение своего стека. agentmemory работает в фоне и устраняет это полностью. + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### vs встроенная память агента + +Каждый ИИ-агент программирования поставляется со встроенной памятью — у Claude Code есть `MEMORY.md`, у Cursor — notepad'ы, у Cline — memory bank. Это работает как стикеры. agentmemory — индексируемая база данных за этими стикерами. + +| | Встроенная (CLAUDE.md) | agentmemory | +|---|---|---| +| Масштаб | Потолок в 200 строк | Без ограничений | +| Поиск | Загружает всё в контекст | BM25 + векторный + граф (только top-K) | +| Цена в токенах | 22K+ при 240 наблюдениях | ~1 900 токенов (на 92 % меньше) | +| Между агентами | Файлы на каждого агента | MCP + REST (любой агент) | +| Координация | Нет | Lease'ы, сигналы, action'ы, routine'ы | +| Наблюдаемость | Читать файлы вручную | Просмотрщик в реальном времени на :3113 | + +--- + +

Как это работает

+ +### Конвейер памяти + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4-уровневая консолидация памяти + +Вдохновлено тем, как мозг человека обрабатывает воспоминания — похоже на консолидацию во время сна. + +| Уровень | Что | Аналогия | +|------|------|---------| +| **Working** | Сырые наблюдения от использования инструментов | Кратковременная память | +| **Episodic** | Сжатые краткие итоги сессий | «Что произошло» | +| **Semantic** | Извлечённые факты и закономерности | «Что я знаю» | +| **Procedural** | Workflow'ы и паттерны принятия решений | «Как это сделать» | + +Воспоминания затухают со временем (кривая Эббингауза). Часто используемые воспоминания усиливаются. Устаревшие — автоматически вытесняются. Противоречия обнаруживаются и разрешаются. + +### Что захватывается + +| Хук | Захватывает | +|------|----------| +| `SessionStart` | Путь к проекту, идентификатор сессии | +| `UserPromptSubmit` | Пользовательские промпты (с приватным фильтром) | +| `PreToolUse` | Паттерны доступа к файлам + обогащённый контекст | +| `PostToolUse` | Имя инструмента, вход, выход | +| `PostToolUseFailure` | Контекст ошибки | +| `PreCompact` | Заново подмешивает память перед компакцией | +| `SubagentStart/Stop` | Жизненный цикл подагентов | +| `Stop` | Итог в конце сессии | +| `SessionEnd` | Маркер завершения сессии | + +### Ключевые возможности + +| Возможность | Описание | +|---|---| +| **Автоматический захват** | Каждое использование инструмента записывается через хуки — никаких ручных усилий | +| **Семантический поиск** | BM25 + векторный + граф знаний со слиянием RRF | +| **Эволюция памяти** | Версионирование, supersession, графы связей | +| **Авто-забывание** | Истечение TTL, обнаружение противоречий, вытеснение по важности | +| **Privacy first** | API-ключи, секреты, теги `` вырезаются до сохранения | +| **Самовосстановление** | Circuit breaker, цепочка fallback-провайдеров, мониторинг состояния | +| **Claude bridge** | Двусторонняя синхронизация с MEMORY.md | +| **Граф знаний** | Извлечение сущностей + обход BFS | +| **Командная память** | Отдельные namespace'ы для общего и приватного у участников команды | +| **Происхождение цитат** | Любую запись памяти можно проследить до исходных наблюдений | +| **Git-снапшоты** | Версионирование, откат и diff состояния памяти | + +--- + + + +Тройной поток извлечения, объединяющий три сигнала: + +| Поток | Что делает | Когда | +|---|---|---| +| **BM25** | Сопоставление по стеммированным ключевым словам с расширением синонимами | Всегда включён | +| **Vector** | Косинусное сходство по плотным эмбеддингам | Если настроен embedding-провайдер | +| **Graph** | Обход графа знаний по сопоставлению сущностей | Если в запросе обнаружены сущности | + +Сливаются через Reciprocal Rank Fusion (RRF, k=60) и диверсифицируются по сессиям (не более 3 результатов на сессию). + +BM25 «из коробки» токенизирует греческий, кириллицу, иврит, арабский и латиницу с диакритикой. Для записей на китайском / японском / корейском поставьте опциональные сегментаторы (`npm install @node-rs/jieba tiny-segmenter`), чтобы CJK-последовательности разбивались на токены уровня слова; без них agentmemory мягко откатывается к токенизации целых последовательностей и выводит одноразовую подсказку в stderr. + +### Провайдеры эмбеддингов + +agentmemory автоматически определяет вашего провайдера. Для лучших результатов поставьте локальные эмбеддинги (бесплатно): + +```bash +npm install @xenova/transformers +``` + +| Провайдер | Модель | Стоимость | Заметки | +|---|---|---|---| +| **Локально (рекомендуется)** | `all-MiniLM-L6-v2` | Бесплатно | Офлайн, +8 пп recall по сравнению только с BM25 | +| Gemini | `gemini-embedding-001` | Бесплатный тариф | 100+ языков, размерности 768/1536/3072 (MRL), вход 2048 токенов. Заменяет `text-embedding-004` ([устарел, отключение 14 янв. 2026](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | 0,02 $/1M | Высочайшее качество | +| Voyage AI | `voyage-code-3` | Платно | Оптимизирован под код | +| Cohere | `embed-english-v3.0` | Бесплатная пробная версия | Общего назначения | +| OpenRouter | Любая модель | Зависит | Мульти-модельный прокси | + +--- + +

MCP-сервер

+ +53 инструмента, 6 ресурсов, 3 промпта и 4 skill'а — самый исчерпывающий MCP-набор для памяти любого агента. + +> **MCP-shim против полного сервера:** опубликованный пакет `@agentmemory/mcp` — это тонкий shim. Он раскрывает полную поверхность из 51 инструмента **только если может достучаться до работающего сервера agentmemory** через `AGENTMEMORY_URL` (режим прокси). Если сервер недоступен, shim откатывается к локальному набору из 7 инструментов (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`). Переменная окружения `AGENTMEMORY_TOOLS=core|all` — *серверный* флаг; задавать её в блоке `env` shim'а бесполезно. Если в Cursor / OpenCode / Gemini CLI видно только 7 инструментов, запустите `npx @agentmemory/agentmemory` (или Docker-стек) и установите `AGENTMEMORY_URL=http://localhost:3111`. + +### 51 инструмент + +
+Базовые инструменты (всегда доступны) + +| Инструмент | Описание | +|------|-------------| +| `memory_recall` | Искать в прошлых наблюдениях | +| `memory_compress_file` | Сжимать markdown-файлы с сохранением структуры | +| `memory_save` | Сохранить инсайт, решение или паттерн | +| `memory_patterns` | Выявить повторяющиеся паттерны | +| `memory_smart_search` | Гибридный семантический + keyword-поиск | +| `memory_file_history` | Прошлые наблюдения о конкретных файлах | +| `memory_sessions` | Список последних сессий | +| `memory_timeline` | Хронологические наблюдения | +| `memory_profile` | Профиль проекта (концепции, файлы, паттерны) | +| `memory_export` | Экспортировать все данные памяти | +| `memory_relations` | Запрос к графу связей | + +
+ +
+Расширенные инструменты (всего 51 — задайте AGENTMEMORY_TOOLS=all) + +| Инструмент | Описание | +|------|-------------| +| `memory_patterns` | Выявить повторяющиеся паттерны | +| `memory_timeline` | Хронологические наблюдения | +| `memory_relations` | Запрос к графу связей | +| `memory_graph_query` | Обход графа знаний | +| `memory_consolidate` | Запустить 4-уровневую консолидацию | +| `memory_claude_bridge_sync` | Синхронизация с MEMORY.md | +| `memory_team_share` | Поделиться с участниками команды | +| `memory_team_feed` | Недавно расшаренные элементы | +| `memory_audit` | Аудит-журнал операций | +| `memory_governance_delete` | Удалить с записью в аудит-журнал | +| `memory_snapshot_create` | Снапшот, версионированный в git | +| `memory_action_create` | Создать задачи с зависимостями | +| `memory_action_update` | Обновить статус action | +| `memory_frontier` | Разблокированные action'ы, отсортированные по приоритету | +| `memory_next` | Самый важный следующий action | +| `memory_lease` | Эксклюзивные lease'ы для action'ов (мультиагентность) | +| `memory_routine_run` | Инстанцировать workflow-routine'ы | +| `memory_signal_send` | Межагентный обмен сообщениями | +| `memory_signal_read` | Чтение сообщений с подтверждениями | +| `memory_checkpoint` | Внешние условные шлюзы | +| `memory_mesh_sync` | P2P-синхронизация между инстансами | +| `memory_sentinel_create` | События-наблюдатели | +| `memory_sentinel_trigger` | Запустить sentinel'ы извне | +| `memory_sketch_create` | Эфемерные графы action'ов | +| `memory_sketch_promote` | Перевести в постоянное состояние | +| `memory_crystallize` | Сжать цепочки action'ов | +| `memory_diagnose` | Проверки состояния | +| `memory_heal` | Авто-исправление зависшего состояния | +| `memory_facet_tag` | Теги вида измерение:значение | +| `memory_facet_query` | Запрос по фасет-тегам | +| `memory_verify` | Трассировка происхождения | + +
+ +### 6 ресурсов · 3 промпта · 4 skill'а + +| Тип | Имя | Описание | +|------|------|-------------| +| Ресурс | `agentmemory://status` | Состояние, число сессий, число записей памяти | +| Ресурс | `agentmemory://project/{name}/profile` | Интеллект на уровне проекта | +| Ресурс | `agentmemory://memories/latest` | 10 последних активных записей памяти | +| Ресурс | `agentmemory://graph/stats` | Статистика графа знаний | +| Промпт | `recall_context` | Поиск + возврат контекстных сообщений | +| Промпт | `session_handoff` | Передача данных между агентами | +| Промпт | `detect_patterns` | Анализ повторяющихся паттернов | +| Skill | `/recall` | Поиск по памяти | +| Skill | `/remember` | Сохранение в долговременную память | +| Skill | `/session-history` | Краткие итоги последних сессий | +| Skill | `/forget` | Удаление наблюдений / сессий | + +### Standalone MCP + +Запуск без полного сервера — для любого MCP-клиента. Подойдёт любое: + +```bash +npx -y @agentmemory/agentmemory mcp # canonical (always available) +npx -y @agentmemory/mcp # shim package alias +``` + +Или добавьте в MCP-конфиг своего агента: + +Большинство агентов (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +Вставьте запись `agentmemory` в существующий объект `mcpServers` хоста, а не заменяйте файл. Для sandbox-клиентов, которые не могут добраться до `localhost` хоста, добавьте `"AGENTMEMORY_FORCE_PROXY": "1"` в блок env и укажите `AGENTMEMORY_URL` на маршрут, доступный из sandbox. + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Скопируйте файл плагина из репозитория: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Просмотрщик реального времени

+ +Автоматически запускается на порту `3113`. Живой поток наблюдений, обозреватель сессий, браузер по памяти, визуализация графа знаний и панель состояния. + +```bash +open http://localhost:3113 +``` + +Сервер просмотрщика по умолчанию слушает на `127.0.0.1`. Эндпоинт `/agentmemory/viewer`, отдаваемый REST'ом, подчиняется обычным правилам bearer-токена `AGENTMEMORY_SECRET`. Заголовки CSP используют nonce скрипта на ответ и отключают inline-атрибуты-обработчики (`script-src-attr 'none'`). + +--- + +

iii Console

+ +Просмотрщик на `:3113` показывает, что ваш агент **запомнил**. [iii console](https://iii.dev/docs/console) показывает, что ваш агент **сделал** — каждая операция памяти как трейс OpenTelemetry, каждая запись KV редактируема, каждая функция вызываема, каждый стрим тэппится. Два окна на одну и ту же память: одно повёрнуто к продукту, другое к движку. + +Наблюдайте, как срабатывает `memory_smart_search`, и видите BM25-скан → поиск эмбеддингов → RRF-слияние → reranker в виде waterfall. Отредактируйте зависший таймер консолидации в браузере KV. Воспроизведите хук `PostToolUse` с изменённым payload. Пин WebSocket-стрима — и смотрите, как наблюдения прилетают в реальном времени. + +agentmemory отдаёт это бесплатно, потому что каждая функция, триггер, scope состояния и стрим — это примитив iii: ничего самописного, нечего инструментировать. + +

+ Страница Workers в iii console — подключённые воркеры, включая инстансы agentmemory, с живым числом функций и метаданными runtime +
+ Страница Workers: каждый подключённый воркер — включая сам agentmemory — с PID, количеством функций, runtime и временем последнего появления. +

+ +**Уже установлено.** Console поставляется вместе с `iii` — отдельный установщик не нужен. + +**Запускать рядом с agentmemory:** + +```bash +# agentmemory viewer holds port 3113, so run the console on 3114. +# Engine REST (3111), WebSocket (3112), and bridge (49134) defaults match agentmemory. +iii console --port 3114 +``` + +Затем откройте `http://localhost:3114`. Добавьте `--enable-flow` для экспериментальной страницы графа архитектуры. + +Переопределяйте эндпоинты движка только если вы их перенесли: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**Что можно делать из console:** + +| Страница | Зачем | +|------|-----------| +| **Workers** | Видеть каждый подключённый воркер и его живые метрики — включая сам воркер agentmemory. | +| **Functions** | Напрямую вызывать любую функцию agentmemory с JSON-payload — удобно для тестов `memory.recall`, `memory.consolidate`, `graph.query` без подключения клиента. | +| **Triggers** | Воспроизводить HTTP-, cron-, event- и state-триггеры — запустить cron консолидации вручную, повторить HTTP-маршрут, эмитировать изменение состояния. | +| **States** | KV-браузер с полным CRUD — сессии, слоты памяти, lifecycle-таймеры, индекс эмбеддингов — редактирование значений на месте. | +| **Streams** | Живой WebSocket-монитор для записей памяти, событий хуков и обновлений наблюдений по мере их прохождения через iii-стримы. | +| **Queues** | Долговечные топики очередей + управление dead-letter. Повтор или сброс упавших job'ов эмбеддинга / компрессии. | +| **Traces** | Виды waterfall / flame / разбивка по сервисам в OpenTelemetry. Фильтр по `trace_id` показывает, какие функции, обращения к БД и embedding-запросы породил отдельный `memory.search`. | +| **Logs** | Структурированные OTEL-логи, фильтруемые и коррелируемые с trace-/span-ID. | +| **Config** | Конфигурация runtime — какие именно воркеры, провайдеры и порты использует ваш движок. | +| **Flow** | (Опционально, `--enable-flow`) Интерактивный граф архитектуры из всех воркеров, триггеров и стримов. | + +

+ Просмотр trace-waterfall в iii console с длительностью каждого span +
+ Traces: waterfall / flame / разбивка по сервисам для каждой операции памяти. +

+ +**Traces уже включены:** + +`iii-config.yaml` поставляется с включённым воркером `iii-observability` (`exporter: memory`, `sampling_ratio: 1.0`, метрики + логи). Дополнительная настройка не нужна — как только agentmemory запускается, каждая операция памяти эмитит trace-span и структурированный лог, который консоль читает. + +Если хотите экспортировать в Jaeger/Honeycomb/Grafana Tempo, измените `exporter: memory` на `exporter: otlp` и укажите эндпоинт коллектора согласно документации по observability в iii. + +> **Внимание:** на самой console аутентификация не применяется — держите её привязанной к `127.0.0.1` (по умолчанию) и никогда не выставляйте наружу. + +--- + +

Powered by iii

+ +agentmemory — это **уже работающий инстанс [iii](https://iii.dev)**. Функции, триггеры, KV-состояние, стримы, OTEL-трейсы — всё это примитивы iii. Вы не ставили Postgres, Redis, Express, pm2 или Prometheus, потому что iii их заменяет. + +Это значит, что одна дополнительная команда расширяет agentmemory целой новой возможностью. + +### Расширить agentmemory одной командой + +```bash +iii worker add iii-pubsub # fan memory writes out to every connected instance +iii worker add iii-cron # scheduled consolidation, decay sweeps, snapshot rotation +iii worker add iii-queue # durable retries for embedding + compression jobs +iii worker add iii-observability # OTEL traces on every memory op (default on) +iii worker add iii-sandbox # run recalled code inside an isolated microVM +iii worker add iii-database # swap in a SQL-backed state adapter +iii worker add mcp # generic MCP host alongside the agentmemory MCP +``` + +Каждый `iii worker add` регистрирует новые функции и триггеры в том же движке, где уже работает agentmemory. Просмотрщик и console подхватывают их мгновенно — без перезагрузки, новой интеграции или нового контейнера. + +| `iii worker add` | Что получаете сверху к agentmemory | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | Память на множестве инстансов: каждое `remember` разлетается, каждое `search` читает объединение | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Жизненный цикл по расписанию — ночная консолидация, еженедельные снапшоты, decay по фиксированному таймеру | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | Надёжные повторы: упавшие job'ы эмбеддинга и компрессии переживают перезапуск, наблюдения не теряются | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | OTEL-трейсы, метрики, логи на каждой функции — подключены в `iii-config.yaml` с первого дня | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | Код, пришедший из `memory_recall`, исполняется внутри одноразовой VM, а не в вашем shell | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | SQL-адаптер состояния, когда дефолтная in-memory KV уже мала | +| [`mcp`](https://workers.iii.dev/workers/mcp) | Поднять дополнительные MCP-серверы рядом с MCP'ом agentmemory, на одном и том же движке | + +Полный реестр: [workers.iii.dev](https://workers.iii.dev). Каждый воркер там собирается из тех же примитивов, что и agentmemory — и тот agentmemory, который у вас уже есть, — один из них. + +### Что заменяет iii + +| Традиционный стек | agentmemory использует | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + векторный индекс в памяти | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | Супервизия воркеров движка iii | +| Prometheus / Grafana | iii OTEL + монитор состояния | +| Самописные плагинные системы | `iii worker add ` | + +**118 исходных файлов · ~21 800 LOC · 950+ тестов · 123 функции · 34 KV-scope'а** — всё на трёх примитивах. Никакого `agentmemory plugin install`. Плагинная система — это сам iii. + +--- + +

Конфигурация

+ +### LLM-провайдеры + +agentmemory автоопределяет провайдера по окружению. По умолчанию никакие вызовы LLM не выполняются, пока вы не настроите провайдера или явно не включите fallback на подписку Claude. + +| Провайдер | Конфигурация | Заметки | +|----------|--------|-------| +| **No-op (по умолчанию)** | Настройка не нужна | LLM-сжатие/резюме ВЫКЛЮЧЕНО. Синтетическое BM25-сжатие и recall продолжают работать. Если вы раньше полагались на fallback подписки Claude — см. `AGENTMEMORY_ALLOW_AGENT_SDK` ниже. | +| Anthropic API | `ANTHROPIC_API_KEY` | Поминутная (token-based) оплата | +| MiniMax | `MINIMAX_API_KEY` | Совместим с Anthropic | +| Gemini | `GEMINI_API_KEY` | Дополнительно включает эмбеддинги | +| OpenRouter | `OPENROUTER_API_KEY` | Любая модель | +| Fallback на подписку Claude | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Только по согласию. Запускает сессии `@anthropic-ai/claude-agent-sdk` — раньше приводил к неограниченной рекурсии Stop-хука, потому больше не по умолчанию. | + +### Выбор модели с учётом стоимости + +Фоновое сжатие выполняется на каждом наблюдении, поэтому выбор модели заметно влияет на ежемесячные расходы. Замеренные данные нагрузки: 635 запросов / 888K токенов / 35 часов активного использования, прогон против трёх моделей OpenRouter по ценам на 2026-05-23. + +| Уровень | Модель | Вход / 1M | Выход / 1M | Стоимость за зафиксированные 35 ч | Заметки | +|------|-------|------------|-------------|---------------------------|-------| +| Рекомендовано | `deepseek/deepseek-v4-pro` | 0,435 $ | 0,87 $ | ~0,46 $ | Хорошее качество сжатия и резюмирования при стоимости ~10× ниже Sonnet. | +| Рекомендовано | `deepseek/deepseek-chat` | 0,27 $ | 1,10 $ | ~0,40 $ | Постарше, но для рабочих нагрузок только на сжатие по-прежнему годится. | +| Рекомендовано | `qwen/qwen3-coder` | 0,45 $ | 1,80 $ | ~0,55 $ | Сильное code-reasoning, если ваши сессии сильно завязаны на код. | +| Premium | `anthropic/claude-sonnet-4.6` | 3,00 $ | 15,00 $ | ~5,02 $ | Высокое качество, но дорого для постоянной фоновой работы. | +| Premium | `openai/gpt-4o` | 2,50 $ | 10,00 $ | ~4,20 $ | Класс, схожий с Sonnet. | +| Избегать | `anthropic/claude-opus-4.6` | 15,00 $ | 75,00 $ | ~25+ $ | Модель класса reasoning; колоссальный перерасход на сжатие. | + +agentmemory выводит runtime-предупреждение, когда `OPENROUTER_MODEL` совпадает с шаблоном premium-уровня. Установите `AGENTMEMORY_SUPPRESS_COST_WARNING=1`, чтобы заглушить его, как только сделаете осознанный выбор. + +Компромисс качество/цена для работы с памятью: сжатие — это задача резюмирования с относительно мягкими требованиями к качеству (резюме перечитывает агент, не пользователь). DeepSeek-V4-Pro / Qwen3-Coder ложатся на этой задаче в пределах погрешности от Sonnet, стоя примерно в 10 раз дешевле. Премиум-модели оставляйте для запросов, которые читаете напрямую. + +Источники: [цены OpenRouter на Sonnet 4.6](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing), [DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro), [заметки о ценах DeepSeek](https://api-docs.deepseek.com/quick_start/pricing/). + +### Мультиагентная память (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +В мультиагентных конфигурациях, где несколько ролей делят один сервер agentmemory (architect / developer / reviewer / researcher / support-agent), `AGENT_ID` помечает каждую запись ролью, которая её сделала. `AGENTMEMORY_AGENT_SCOPE` управляет тем, фильтрует ли recall по этому тегу. + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # optional; default "shared" +``` + +Два режима: + +| Режим | Помечать записи | Фильтровать recall | Когда использовать | +|------|------------|---------------|-------------| +| `shared` (по умолчанию) | да | нет | Общий контекст между агентами с аудит-журналом. Architect видит, что отметил developer, но каждая запись фиксирует, кто это сказал. | +| `isolated` | да | да | Строгое разделение. Architect никогда не увидит наблюдения / записи памяти / сессии developer'а. | + +Что помечается, когда `AGENT_ID` задан: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. Роль течёт по `api::session::start` → `mem::observe` → `mem::compress` → KV. + +Что фильтруется в режиме `isolated`: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Каждый эндпоинт принимает `?agentId=` для переопределения на конкретный запрос и `?agentId=*`, чтобы полностью выйти из env-scope. `/memories` дополнительно принимает `?includeOrphans=true`, чтобы поднять «доисторические» записи памяти, у которых `agentId` не определён. + +Переопределение в самом вызове на уровне SDK / REST: каждый мутирующий эндпоинт (`/session/start`, `/remember`) принимает поле `agentId` в теле запроса, которое выигрывает у env. Полезно для runtime'ов, прогоняющих много ролей через один серверный процесс. + +Когда `AGENT_ID` не задан, память остаётся без scope (legacy-поведение: ни тегов, ни фильтров). + +### Порты + +agentmemory + iii-engine по умолчанию занимают четыре порта. Если перезапуск падает с `port in use`, эта таблица подскажет, какой процесс искать. + +| Порт | Процесс | Назначение | Override через env | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | Внутренний streams-воркер (используется agentmemory + просмотрщиком) | `III_STREAMS_PORT` | +| `3113` | agentmemory | Просмотрщик в реальном времени (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — воркеры регистрируются здесь, по нему же течёт OTel-телеметрия | `III_ENGINE_URL` (полный URL, по умолчанию `ws://localhost:49134`) | + +Очистка завис­ших процессов, если порты остаются занятыми после падения: + +```bash +# macOS / Linux — find whatever is on each port and kill it +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` корректно вычищает и воркер, и pidfile движка при штатном завершении. Ручная очистка выше нужна только в посткрэшевом сценарии, когда ни один pidfile не остался. + +### Конфигурационный файл + +Помещайте runtime-конфигурацию agentmemory в `~/.agentmemory/.env`, а не экспортируйте переменные в каждой сессии shell. Если просмотрщик показывает подсказку настройки вида `export ANTHROPIC_API_KEY=...`, скопируйте её в этот файл как `ANTHROPIC_API_KEY=...` без префикса `export`, затем перезапустите agentmemory. + +Переменные окружения процесса по-прежнему работают и имеют приоритет над значениями из файла. + +В Windows тот же файл лежит в `%USERPROFILE%\.agentmemory\.env`: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +Чтобы протестировать с подпиской Claude Code Pro/Max вместо API-ключа, включите её явно: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +В этом же файле включите возможности графа или консолидации, если они нужны: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### Переменные окружения + +Создайте `~/.agentmemory/.env`: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +124 эндпоинта на порту `3111`. REST API по умолчанию слушает на `127.0.0.1`. Защищённые эндпоинты требуют `Authorization: Bearer `, когда установлен `AGENTMEMORY_SECRET`, а эндпоинты mesh-синхронизации требуют `AGENTMEMORY_SECRET` на обоих узлах. + +
+Ключевые эндпоинты + +| Метод | Путь | Описание | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | Проверка состояния (всегда публична) | +| `POST` | `/agentmemory/session/start` | Запуск сессии + получение контекста | +| `POST` | `/agentmemory/session/end` | Завершение сессии | +| `POST` | `/agentmemory/observe` | Захват наблюдения | +| `POST` | `/agentmemory/smart-search` | Гибридный поиск | +| `POST` | `/agentmemory/context` | Генерация контекста | +| `POST` | `/agentmemory/remember` | Сохранить в долговременную память | +| `POST` | `/agentmemory/forget` | Удалить наблюдения | +| `POST` | `/agentmemory/enrich` | Контекст файла + записи памяти + баги | +| `GET` | `/agentmemory/profile` | Профиль проекта | +| `GET` | `/agentmemory/export` | Экспорт всех данных | +| `POST` | `/agentmemory/import` | Импорт из JSON | +| `POST` | `/agentmemory/graph/query` | Запрос к графу знаний | +| `POST` | `/agentmemory/team/share` | Расшарить в команду | +| `GET` | `/agentmemory/audit` | Аудит-журнал | + +Полный список эндпоинтов: [`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Разработка

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 950+ tests +npm run test:integration # API tests (requires running services) +``` + +**Требования:** Node.js >= 20, [iii-engine](https://iii.dev/docs) или Docker + +

Лицензия

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.tr-TR.md b/READMEs/README.tr-TR.md new file mode 100644 index 0000000..28fad80 --- /dev/null +++ b/READMEs/README.tr-TR.md @@ -0,0 +1,1380 @@ +

+ agentmemory — AI kodlama ajanları için kalıcı bellek +

+ +

+ + Kodlama ajanınız her şeyi hatırlasın. Aynı şeyi bir daha açıklamayın. + Built on iii engine +
+ Claude Code, Cursor, Gemini CLI, Codex CLI, Hermes, OpenClaw, pi, OpenCode ve her MCP istemcisi için kalıcı bellek. +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Design doc: 1200 stars / 172 forks on the gist +

+ +

+ Bu gist, Karpathy'nin LLM Wiki desenini güven puanlaması, yaşam döngüsü, bilgi grafları ve hibrit aramayla genişletir: agentmemory bunun uygulamasıdır. +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ agentmemory demo +

+ +

+ Kurulum • + Hızlı Başlangıç • + Kıyaslamalar • + Rakiplerle Karşılaştırma • + Ajanlar • + Nasıl Çalışır • + MCP • + Görüntüleyici • + iii Konsolu • + iii ile çalışır • + Yapılandırma • + API +

+ +--- + +## Kurulum + +```bash +npm install -g @agentmemory/agentmemory # bir kez — `agentmemory` PATH'te kullanılabilir +# macOS/Linux sistem Node kurulumlarında EACCES hatası alırsanız şununla deneyin: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # bellek sunucusunu :3111 üzerinde başlat +agentmemory demo # örnek oturumlar yükle + recall'u kanıtla +agentmemory connect claude-code # ajanınızı bağlayın (ayrıca: codex, cursor, gemini-cli, ...) +``` + +Veya `npx` ile (kurulum gerekmez): + +```bash +npx @agentmemory/agentmemory +``` + +Dikkat — npx sürüm bazında önbelleğe alır. Eğer çıplak bir `npx @agentmemory/agentmemory` eski bir sürümü servis ediyorsa, en güncelini `npx -y @agentmemory/agentmemory@latest` ile zorlayın veya önbelleği `rm -rf ~/.npm/_npx` ile bir kez temizleyin (macOS/Linux; Windows'ta `%LOCALAPPDATA%\npm-cache\_npx` dizinini silin). v0.9.16+ sonrası ilk npx çalıştırması, çıplak `agentmemory` komutunun her yerden çalışması için global kurulum yapmanızı satır içi olarak sorar. + +Tüm seçenekler aşağıdaki [Hızlı Başlangıç](#quick-start) bölümünde. Ajana özel bağlantılar için [Her ajanla çalışır](#works-with-every-agent) bölümüne bakın. + +--- + +

Works with every agent

+ +agentmemory; hook'ları, MCP'yi veya REST API'yi destekleyen her ajanla çalışır. Tüm ajanlar aynı bellek sunucusunu paylaşır. + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+yerel eklenti + 12 hook + MCP +
+Codex CLI
+Codex CLI
+yerel eklenti + 6 hook + MCP +
+OpenClaw
+OpenClaw
+yerel eklenti + MCP +
+Hermes
+Hermes
+yerel eklenti + MCP +
+pi
+pi
+yerel eklenti + MCP +
+OpenHuman
+OpenHuman
+yerel Memory trait arka uç +
+Cursor
+Cursor
+MCP sunucusu +
+Gemini CLI
+Gemini CLI
+MCP sunucusu +
+OpenCode
+OpenCode
+22 hook + MCP + eklenti +
+Cline
+Cline
+MCP sunucusu +
+Goose
+Goose
+MCP sunucusu +
+Kilo Code
+Kilo Code
+MCP sunucusu +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP sunucusu +
+Windsurf
+Windsurf
+MCP sunucusu +
+Roo Code
+Roo Code
+MCP sunucusu +
+ +

+ MCP veya HTTP konuşan herhangi bir ajanla çalışır. Tek sunucu, tüm ajanlar arasında paylaşılan bellek. +

+ +--- + +Her oturumda aynı mimariyi tekrar tekrar anlatıyorsunuz. Aynı bug'ları yeniden keşfediyorsunuz. Aynı tercihleri yeniden öğretiyorsunuz. Yerleşik bellek (CLAUDE.md, .cursorrules) 200 satırda tıkanır ve eskir. agentmemory bunu düzeltir. Ajanınızın yaptıklarını sessizce yakalar, aranabilir belleğe sıkıştırır ve bir sonraki oturum başladığında doğru bağlamı enjekte eder. Tek komut. Ajanlar arası çalışır. + +**Neler değişiyor:** Oturum 1'de JWT kimlik doğrulamasını kuruyorsunuz. Oturum 2'de hız sınırlaması istiyorsunuz. Ajan zaten biliyor: kimlik doğrulamanız `src/middleware/auth.ts` içinde jose middleware kullanıyor, testleriniz token doğrulamasını kapsıyor ve Edge uyumluluğu için jsonwebtoken yerine jose'yi seçtiniz. Yeniden anlatma yok. Kopyala-yapıştır yok. Ajan basitçe *biliyor*. + +```bash +npx @agentmemory/agentmemory +``` + +> **v0.9.0'da yeni** — [agent-memory.dev](https://agent-memory.dev) tanıtım sitesi, dosya sistemi bağlayıcısı (`@agentmemory/fs-watcher`), bağımsız MCP artık çalışan sunucuya proxy yapıyor (böylece hook'lar ve görüntüleyici hemfikir), her silme yolunda kodlanmış denetim politikası, küçük Node süreçlerinde sağlık `memory_critical` olarak işaretlenmiyor. Tüm notlar [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18) içinde. + +--- + +

Benchmarks

+ + + + + + +
+ +### Geri Getirme Doğruluğu + +**coding-agent-life-v1** (kurum içi corpus, sandbox-yeniden üretilebilir) + +| Adaptör | P@5 | R@5 | Top-5 isabet oranı | p50 gecikme | +|---|---|---|---|---| +| **agentmemory hibrit** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep referansı | 0.267 | 0.967 | 15 / 15 | 0 ms | + +%100 Top-5 isabet oranı. Aynı girdide grep referansından **2.2×** daha iyi hassasiyet. Tam tip bazında döküm: [`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 soru) + +| Sistem | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| Yalnız BM25 yedeği | 86.2% | 94.6% | 71.5% | + + + +### Token Tasarrufu + +| Yaklaşım | Token/yıl | Maliyet/yıl | +|---|---|---| +| Tam bağlamı yapıştır | 19.5M+ | İmkansız (pencereyi aşar) | +| LLM-özetlenmiş | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + yerel embedding | ~170K | **$0** | + +
+ +> Embedding modeli: `all-MiniLM-L6-v2` (yerel, ücretsiz, API anahtarı gerekmez). Tam raporlar: [`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md), [`benchmark/QUALITY.md`](../benchmark/QUALITY.md), [`benchmark/SCALE.md`](../benchmark/SCALE.md). Rakip karşılaştırması: [`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory'nin mem0, Letta, Khoj, claude-mem, Hippo ile karşılaştırması. + +**Yerel olarak yeniden üretin:** [`eval/README.md`](../eval/README.md) — LongMemEval `_s` (genel 500 soru) + `coding-agent-life-v1` (kurum içi 15 oturum corpus) için adaptör-takılabilir harness. Grep / vektör / agentmemory adaptörleri yan yana puanlanır, NDJSON çıktısı, yayımlanan puan tabloları [`docs/benchmarks/`](../docs/benchmarks/) içine düşer. + +**[codegraph](https://github.com/colbymchenry/codegraph), [Understand Anything](https://github.com/Lum1104/Understand-Anything) ve [Graphify](https://github.com/safishamsi/graphify) ile birlikte çalışır.** Kod-graf indeksleme, çok-ajanlı build pipeline'ları ve doküman / PDF / görsel / video boyunca daha geniş bilgi grafları. agentmemory çalışmayı hatırlar; bu üç proje bağlam katmanının geri kalanını aydınlatır. Tarifler + soru-yönlendirme tablosu: [`docs/recipes/pairings.md`](../docs/recipes/pairings.md). + +--- + +

vs Competitors

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)Yerleşik (CLAUDE.md)
TürBellek motoru + MCP sunucusuBellek katmanı API'siTam ajan runtime'ıStatik dosya
Geri getirme R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/A (grep)
Otomatik yakalama12 hook (sıfır manuel çaba)Manuel add() çağrılarıAjan kendi düzenlerManuel düzenleme
AramaBM25 + Vektör + Graf (RRF füzyonu)Vektör + GrafVektör (arşiv)Her şeyi bağlama yükler
Çoklu ajanMCP + REST + lease'ler + sinyallerAPI (koordinasyon yok)Yalnızca Letta runtime'ı içindeAjan başına dosya
Framework bağımlılığıYok (herhangi bir MCP istemcisi)YokYüksek (Letta kullanılmalı)Ajan başına format
Harici bağımlılıklarYok (SQLite + iii-engine)Qdrant / pgvectorPostgres + vektör DBYok
Bellek yaşam döngüsü4 katmanlı konsolidasyon + decay + otomatik-unutmaPasif çıkarımAjan-yönetimliManuel ayıklama
Token verimliliği~1,900 token/oturum ($10/yıl)Entegrasyona göre değişirÇekirdek bellek bağlamda240 gözlemde 22K+ token
Gerçek zamanlı görüntüleyiciVar (port 3113)Bulut panelBulut panelYok
Self-hostedEvet (varsayılan)İsteğe bağlıİsteğe bağlıEvet
+ +--- + +

Quick Start

+ +Uyumluluk: bu sürüm kararlı `iii-sdk` `^0.11.0` ve iii-engine v0.11.x'i hedefler. + +### 30 saniyede deneyin + +```bash +# Terminal 1: sunucuyu başlatın +npx @agentmemory/agentmemory + +# Terminal 2: örnek veriyi yükleyin ve geri çağırmayı iş başında görün +npx @agentmemory/agentmemory demo +``` + +`demo`, 3 gerçekçi oturum yükler (JWT auth, N+1 sorgu düzeltmesi, hız sınırlaması) ve bunlar üzerinde anlamsal aramalar çalıştırır. "veritabanı performans optimizasyonu" araması yaptığınızda "N+1 sorgu düzeltmesi"ni bulduğunu göreceksiniz — anahtar kelime eşleştirmesi bunu yapamaz. + +Belleğin canlı oluşumunu izlemek için `http://localhost:3113` adresini açın. + +### Önerilen: globally kurun + +`npx` sürüm bazında önbelleğe alır. Geçen hafta `npx @agentmemory/agentmemory@0.9.14`'ü çalıştırdıysanız, çıplak bir `npx @agentmemory/agentmemory` `~/.npm/_npx/`'ten en son sürümü değil, eski 0.9.14'ü servis edebilir. Bir kez kurun ve çıplak `agentmemory` komutu her yerde çalışsın: + +```bash +npm install -g @agentmemory/agentmemory +# macOS/Linux sistem Node kurulumlarında EACCES hatası alırsanız şununla deneyin: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # sunucuyu başlatın (npx şekliyle aynı) +agentmemory stop # kapatın +agentmemory remove # oluşturduğumuz her şeyi kaldırın +agentmemory connect claude-code # tek bir ajanı bağlayın +agentmemory doctor # interaktif teşhis + düzeltme istemleri +``` + +v0.9.16 ve sonrası ile birlikte, ilk npx çalıştırması global kurmanızı satır içi olarak ister — bir kez `Y` yanıtlayın, hazırsınız. Atlarsanız, taze bir indirme için şunlardan birine geri dönün: + +```bash +npx -y @agentmemory/agentmemory@latest # npm'den en güncelini zorlar (platformlar arası) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # yalnız macOS/Linux (POSIX shell) +``` + +Windows / PowerShell'de eşdeğer cache temizleme komutu `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` şeklindedir — yukarıdaki `npx -y ...@latest` formu platformlar arası seçenektir. + +### Oturum Tekrar Oynatma (Session Replay) + +agentmemory'nin kaydettiği her oturum tekrar oynatılabilir. Görüntüleyiciyi açın, **Replay** sekmesini seçin ve zaman çizelgesini tarayın: istemler, araç çağrıları, araç sonuçları ve yanıtlar; oynat/duraklat, hız kontrolü (0.5×–4×) ve klavye kısayollarıyla (boşluk geçiş, oklar adım atlama) ayrı olaylar olarak görüntülenir. + +Halihazırda içeri aktarmak istediğiniz eski Claude Code JSONL kayıtlarınız mı var? + +```bash +# Varsayılan ~/.claude/projects altındaki her şeyi içeri aktar +npx @agentmemory/agentmemory import-jsonl + +# Veya tek bir dosya içeri aktar +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +İçeri aktarılan oturumlar yerli olanların yanında Replay seçicisinde görünür. Arka planda her giriş `mem::replay::load`, `mem::replay::sessions` ve `mem::replay::import-jsonl` iii fonksiyonları üzerinden yönlendirilir — yan kanal sunucu yok. + +### Yükseltme / Bakım + +Yerel runtime'ınızı bilinçli olarak güncellemek istediğinizde bakım komutunu kullanın: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +Uyarı: bu komut mevcut çalışma alanını/runtime'ı değiştirir. JavaScript bağımlılıklarını güncelleyebilir ve sabitlenmiş `iiidev/iii:0.11.2` Docker imajını çekebilir. Asla sabitlenmemiş ya da daha yeni bir iii motoru kurmaz. + +Uygulama detayları `src/cli.ts` içinde (`src/cli.ts:544-595` bölgesi civarında `runUpgrade`'a bakın). + +### Claude Code (tek blok, yapıştırın) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Eklenti kurulumu olmadan Claude Code (MCP-bağımsız yol) + +Eğer `/plugin install` kullanmak yerine agentmemory'nin MCP sunucusunu doğrudan `~/.claude.json` üzerinden bağlarsanız, Claude Code `${CLAUDE_PLUGIN_ROOT}`'u asla çözmez ve hook scriptlerini `~/.claude/settings.json` içinde mutlak yollara işaret etmek zorunda kalırsınız. Bu yollar genellikle agentmemory sürümünü gömer (örn. `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`), bu yüzden bir sonraki yükseltme her hook'u sessizce kırar. + +Geçici çözüm: + +```bash +agentmemory connect claude-code --with-hooks +``` + +Bu, aynı hook komutlarını `~/.claude/settings.json` içine, kurulu `@agentmemory/agentmemory` paketinin paketli `plugin/` dizinine çözülmüş mutlak yollarla birleştirir. agentmemory'yi yükselttikten sonra yolları yenilemek için komutu yeniden çalıştırın. Aynı dosyadaki kullanıcı girdileri korunur; yalnızca önceki agentmemory girdileri değiştirilir. `/plugin install` yolunu kullanmak hâlâ önerilen yaklaşımdır. +Uzak veya korumalı deployment'lar için Claude Code'u `AGENTMEMORY_URL` ve `AGENTMEMORY_SECRET` ayarlanmış olarak başlatın. Eklenti her iki değeri de paketli MCP sunucusuna geçirir; `AGENTMEMORY_URL` boş olduğunda MCP shim'i `http://localhost:3111`'i kullanır. + +### Codex CLI (Codex eklenti platformu) + +```bash +# 1. ayrı bir terminalde bellek sunucusunu başlatın +npx @agentmemory/agentmemory + +# 2. agentmemory marketplace'i kaydedin ve eklentiyi kurun +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Codex eklentisi, Claude Code eklentisiyle aynı `plugin/` dizininden gelir. Şunları kaydeder: + +- `@agentmemory/mcp` MCP sunucusu olarak (`AGENTMEMORY_URL` çalışan bir agentmemory sunucusuna işaret ettiğinde tüm 51 tool'u proxy yapar; erişilebilir sunucu yoksa yerel olarak 7 tool'a düşer) +- 6 yaşam döngüsü hook'u: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop` +- 4 skill: `/recall`, `/remember`, `/session-history`, `/forget` + +Codex'in hook motoru, hook alt süreçlerine `CLAUDE_PLUGIN_ROOT` enjekte eder (bkz. [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)), bu sayede aynı hook scriptleri her iki host'ta da çoğaltma yapmadan çalışır. Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure olayları yalnızca Claude Code'a özeldir ve Codex için kaydedilmez. + +#### Codex Desktop: eklenti hook'ları şu anda sessiz (geçici çözüm mevcut) + +`CodexHooks` ve `PluginHooks` her ikisi de [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs) içinde stable + varsayılan olarak etkin, ancak Codex Desktop sürümleri şu anda eklenti-yerel `hooks.json`'u dağıtmıyor ([openai/codex#16430](https://github.com/openai/codex/issues/16430)). MCP tool'ları hâlâ çalışıyor; yalnızca yaşam döngüsü gözlemleri eksik. + +Düzeltme upstream'e iner inmez, aynı hook komutlarını global `~/.codex/hooks.json` içine yansıtın: + +```bash +agentmemory connect codex --with-hooks +``` + +Bu, `~/.codex/hooks.json`'a paketli scriptlere mutlak yollarla atıfta bulunan idempotent bir blok ekler (user-scope'ta `${CLAUDE_PLUGIN_ROOT}` genişlemesi gerekmez). agentmemory'yi yükselttikten sonra yolları yenilemek için aynı komutu yeniden çalıştırın. Aynı dosyadaki kullanıcı girdileri korunur; yalnızca önceki agentmemory girdileri değiştirilir. + +
+OpenClaw (bu istemi yapıştırın) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +Tam kılavuz: [`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent (bu istemi yapıştırın) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +Tam kılavuz: [`integrations/hermes/`](../integrations/hermes/) + +
+ +### Diğer ajanlar + +Bellek sunucusunu başlatın: `npx @agentmemory/agentmemory` + +agentmemory girdisi, `mcpServers` şeklini kullanan her host'ta (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI, OpenClaw) **aynı MCP sunucu bloğudur**: + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**Bu girdiyi host'un yapılandırma dosyasındaki mevcut `mcpServers` nesnesine birleştirin** — dosyayı değiştirmeyin. Dosyada zaten başka sunucular varsa, `agentmemory`'yi `mcpServers` içindeki başka bir anahtar olarak yanlarına ekleyin. `mcpServers` tamamen eksikse, bloğu `{ "mcpServers": { ... } }` içine yapıştırın. `${VAR}` yer tutucuları, MCP-sunucu lansmanında shell'den `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET`'i miras alır — ayarsız değişkenler boş string geçirir ve shim `http://localhost:3111`'e geri döner. Bir tane bağlı girdi hem yerel hem uzak (k8s / reverse-proxy'li) dağıtımları kapsar. + +| Ajan | Yapılandırma dosyası | Notlar | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | `mcpServers` içine birleştirin. Web sitesinde tek tıklamayla deeplink de mevcut. | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | `mcpServers` içine birleştirin. Düzenlemeden sonra Claude Desktop'ı yeniden başlatın. | +| **Cline / Roo Code / Kilo Code** | Cline MCP ayarları (Settings UI → MCP Servers → Edit) | Aynı `mcpServers` bloğu. | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | Aynı `mcpServers` bloğu. | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user` (otomatik birleştirir). | +| **OpenClaw** | OpenClaw MCP yapılandırması | Aynı `mcpServers` bloğu veya daha derin [bellek eklentisi](../integrations/openclaw/) kullanın. | +| **Codex CLI (yalnız MCP)** | `.codex/config.toml` | TOML şekli: `codex mcp add agentmemory -- npx -y @agentmemory/mcp` veya manuel olarak `[mcp_servers.agentmemory]` ekleyin. | +| **Codex CLI (tam eklenti)** | Codex eklenti marketplace | `codex plugin marketplace add rohitg00/agentmemory` ardından `codex plugin add agentmemory@agentmemory`. MCP + 6 yaşam döngüsü hook'u (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, Stop) + 4 skill kaydeder. Codex Desktop'ta, [openai/codex#16430](https://github.com/openai/codex/issues/16430) inene kadar `agentmemory connect codex --with-hooks` da çalıştırın — eklenti hook'ları şu anda orada sessiz. | +| **OpenCode (yalnız MCP)** | `opencode.json` | Farklı şekil — üst seviye `mcp` anahtarı, komut dizi olarak: `{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`. | +| **OpenCode (tam eklenti)** | `plugin/opencode/` | Oturum yaşam döngüsü, mesajlar, araçlar, hataları kapsayan 22 otomatik yakalama hook'u. İki slash komut (`/recall`, `/remember`). `plugin/opencode/`'u OpenCode çalışma alanınıza kopyalayın ve eklenti girdisini `opencode.json`'a ekleyin. Tam hook tablosu + gap analizi için [`plugin/opencode/README.md`](../plugin/opencode/README.md) bakın. | +| **pi** | `~/.pi/agent/extensions/agentmemory` | [`integrations/pi`](../integrations/pi/)'yi kopyalayın ve pi'yi yeniden başlatın. | +| **Hermes Agent** | `~/.hermes/config.yaml` | Daha derin [bellek sağlayıcı eklentisi](../integrations/hermes/)'ni `memory.provider: agentmemory` ile kullanın. | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` standart `mcpServers` bloğunu yazar. Hook yükü Claude Code ile alan-uyumludur, bu yüzden mevcut 12 hook scripti değişiklik yapmadan çalışır — aynı `settings.json`'daki `hooks` bölümü üzerinden bağlayın. | +| **Antigravity** (Gemini CLI'nin yerini alır) | `mcp_config.json` (Antigravity'nin User dizininde) | `agentmemory connect antigravity` standart `mcpServers` bloğunu yazar. macOS: `~/Library/Application Support/Antigravity/User/`. Linux: `~/.config/Antigravity/User/`. 2026-06-18 Gemini CLI sonlandırılması sonrasında kullanın. | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` kullanıcı-seviyesi yapılandırmayı yazar. Çalışma alanı override'ları kodunuzun yanındaki `.kiro/settings/mcp.json`'a gider. | +| **Goose** | Goose MCP ayarları UI | Aynı `mcpServers` bloğu. | +| **Aider** | n/a | REST API ile doğrudan konuşun: `curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`. | +| **Herhangi bir ajan (32+)** | n/a | `npx skillkit install agentmemory` host'u otomatik algılar ve birleştirir. | + +**Sandbox'lı MCP istemcileri** (Flatpak / Snap / kısıtlayıcı container'lar) host'un `localhost`'una erişemez: ayrıca `env` bloğunda `"AGENTMEMORY_FORCE_PROXY": "1"` ayarlayın ve `AGENTMEMORY_URL`'i sandbox'ın gerçekten erişebileceği bir rotaya yönlendirin (örn. LAN IP'niz). + +### Programatik erişim (Python / Rust / Node) + +agentmemory çekirdek işlemlerini iii fonksiyonları olarak kaydeder (`mem::remember`, `mem::observe`, `mem::context`, `mem::smart-search`, `mem::forget`). iii SDK'sı olan herhangi bir dil, bunları doğrudan `ws://localhost:49134` üzerinden çağırabilir — dil başına ayrı bir REST istemcisi yok. + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +Çalışan örnek: [`examples/python/`](../examples/python/) (quickstart + gözlem/recall akışı). iii runtime'ı olmayan host'lar için REST `:3111` üzerinde kullanılmaya devam eder. + +### Kaynaktan + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +Bu, `iii` zaten kuruluysa yerel bir `iii-engine` ile agentmemory'yi başlatır veya Docker mevcutsa Docker Compose'a düşer. REST, stream'ler ve görüntüleyici varsayılan olarak `127.0.0.1`'e bağlanır. + +`iii-engine`'i manuel olarak kurun. **agentmemory şu anda `iii-engine`'i `v0.11.2`'ye sabitliyor** — `v0.11.6`, agentmemory'nin henüz refactor edilmediği yeni bir sandbox-her-şey-üzerinden-`iii worker add` modelini tanıtıyor. Refactor geldiğinde sabitleme kaldırılır. Sandbox modeline manuel olarak geçtiyseniz `AGENTMEMORY_III_VERSION=` ile override edin. + +- **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:** `aarch64-apple-darwin`'i `x86_64-apple-darwin` ile değiştirin +- **Linux x64:** `x86_64-unknown-linux-gnu` ile değiştirin +- **Linux arm64:** `aarch64-unknown-linux-gnu` ile değiştirin +- **Windows:** [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2)'den `iii-x86_64-pc-windows-msvc.zip`'i indirin, `iii.exe`'yi çıkarın, PATH'e ekleyin + +Veya Docker kullanın (paketli `docker-compose.yml` `iiidev/iii:0.11.2`'yi çeker). Tam dokümanlar: [iii.dev/docs](https://iii.dev/docs). + +### Windows + +agentmemory Windows 10/11'de çalışır, ancak yalnızca Node.js paketi yeterli değildir — arka planda çalışan bir süreç olarak `iii-engine` runtime'ı (ayrı yerel ikilik) da gerekir. Resmi upstream kurucu bir `sh` scripti ve bugün için PowerShell kurucusu veya scoop/winget paketi yok, bu yüzden Windows kullanıcılarının iki yolu var: + +**Seçenek A — Önceden derlenmiş Windows ikiliği (önerilen):** + +```powershell +# 1. Tarayıcınızda https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 açın +# (engine v0.11.6+'nın gerektirdiği yeni sandbox modeli için +# agentmemory refactor edilene kadar v0.11.2'ye sabitliyoruz) +# 2. iii-x86_64-pc-windows-msvc.zip indirin +# (veya ARM makinedeyseniz iii-aarch64-pc-windows-msvc.zip) +# 3. iii.exe'yi PATH'te bir yere çıkarın veya şuraya yerleştirin: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory bu konumu otomatik kontrol eder) +# 4. Doğrulayın: +iii --version +# Şunu yazmalı: 0.11.2 + +# 5. Ardından agentmemory'yi her zamanki gibi çalıştırın: +npx -y @agentmemory/agentmemory +``` + +**Seçenek B — Docker Desktop:** + +```powershell +# 1. Windows için Docker Desktop kurun +# 2. Docker Desktop'ı başlatın ve engine'in çalıştığından emin olun +# 3. agentmemory'yi çalıştırın — paketli compose dosyasını otomatik başlatır: +npx -y @agentmemory/agentmemory +``` + +**Seçenek C — yalnızca bağımsız MCP (engine yok):** yalnızca ajanınız için MCP araçlarına ihtiyacınız varsa ve REST API'sine, görüntüleyiciye veya cron işlerine gerek yoksa engine'i tamamen atlayın: + +```powershell +npx -y @agentmemory/agentmemory mcp +# veya shim paketi üzerinden: +npx -y @agentmemory/mcp +``` + +**Windows için teşhis:** `npx @agentmemory/agentmemory` başarısız olursa, gerçek engine stderr'ini görmek için `--verbose` ile yeniden çalıştırın. Yaygın hata türleri: + +| Belirti | Düzeltme | +|---|---| +| `iii-engine process started` ardından `did not become ready within 15s` | Engine başlatma sırasında çöktü — `--verbose` ile yeniden çalıştırın, stderr'i kontrol edin | +| `Could not start iii-engine` | Ne `iii.exe` ne de Docker kurulu. Yukarıdaki Seçenek A veya B'ye bakın | +| Port çakışması | `netstat -ano \| findstr :3111` ile neyin bağlı olduğunu görün, ardından öldürün veya `--port ` kullanın | +| Docker kurulu olsa bile Docker fallback atlanıyor | Docker Desktop'ın gerçekten çalıştığından emin olun (sistem tepsisi simgesi) | + +> Not: iii **motoru** önceden derlenmiş bir ikiliktir, bir cargo crate'i değildir — onu `cargo install` ile kurmaya çalışmayın. (iii **SDK'ları** crates.io, npm ve PyPI'de yayımlanmıştır, ancak agentmemory bunlara ihtiyaç duymaz.) Desteklenen motor kurulum yöntemleri, hepsi v0.11.2'ye sabitlenmiştir: yukarıdaki önceden derlenmiş v0.11.2 ikiliği, sürüm sabitlemesi **ile** upstream `sh` kurulum scripti `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh` (macOS/Linux) ve Docker imajı `iiidev/iii:0.11.2`. Yalın bir `install.sh | sh`, agentmemory'nin desteklemediği **en son** motoru kurar — her zaman `VERSION=0.11.2` geçirin. Hepsinden kolayı: sadece `npx @agentmemory/agentmemory` çalıştırın; bu, sabitlenmiş motoru sizin için `~/.agentmemory/bin` dizinine indirir. + +--- + +

Deploy

+ +Yönetilen host'lar için tek tıklamayla şablonlar. Her biri, +npm'den `@agentmemory/agentmemory`'yi çeken ve iii engine +ikilisini resmi `iiidev/iii` Docker Hub imajından kopyalayan +kendi kendine yeten bir Dockerfile içerir — önceden derlenmiş +bir agentmemory imajı gerekmez. Kalıcı depolama `/data`'ya +bağlanır; ilk açılış entrypoint'i, npm-paketli iii yapılandırmasını +(ki `127.0.0.1`'e bağlanır) `0.0.0.0`'a bağlanan ve mutlak +`/data` yollarını kullanan deploy-ayarlı bir tanesiyle üzerine +yazar, HMAC secret'ını üretir, ardından agentmemory CLI'sini +exec etmeden önce `gosu` aracılığıyla yetkileri `root`'tan +`node`'a düşürür. + +

+ Deploy to fly.io + Deploy to Railway +

+ +Render'ın tek-tıklamayla deploy düğmesi, depo kökünde `render.yaml` +gerektirir; biz bunu bilerek temiz tutuyoruz. Deponun içindeki blueprint'e +manuel olarak işaret etmek için [`deploy/render/`](../deploy/render/README.md) içinde belgelenen Render Blueprint akışını kullanın. + +Tam kurulum detayları (HMAC yakalama, görüntüleyici SSH tüneli, +döndürme, yedekleme, maliyet alt sınırları) [`deploy/`](../deploy/README.md) içinde: + +- [`deploy/fly`](../deploy/fly/README.md) — `auto_stop_machines = "stop"` ile + tek makine; en ucuz boşta çalışma. +- [`deploy/railway`](../deploy/railway/README.md) — Hobby planı sabit ücret, + panelden volume. +- [`deploy/render`](../deploy/render/README.md) — Blueprint akışı, + ücretli planlarda otomatik disk snapshot'ları. +- [`deploy/coolify`](../deploy/coolify/README.md) — kendi VPS'inizde + [Coolify](https://coolify.io/self-hosted) üzerinden self-hosted; aynı + Docker Compose stack'i, host ve verinin sahibi sizsiniz. + +Yalnızca `3111` portu yayımlanır. `3113`'teki görüntüleyici container içinde +loopback'e bağlı kalır — her şablonun README'si ona ulaşmak için SSH-tünel +desenini belgeler. + +--- + +

Why agentmemory

+ +Her kodlama ajanı, oturum sona erdiğinde her şeyi unutur. Her oturumun ilk 5 dakikasını yığınınızı yeniden anlatarak harcarsınız. agentmemory arka planda çalışır ve bunu tamamen ortadan kaldırır. + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### Yerleşik ajan belleğiyle karşılaştırma + +Her AI kodlama ajanı yerleşik bellekle gelir — Claude Code'da `MEMORY.md`, Cursor'da notepad, Cline'da memory bank var. Bunlar yapışkan notlar gibi çalışır. agentmemory, o yapışkan notların ardındaki aranabilir veritabanıdır. + +| | Yerleşik (CLAUDE.md) | agentmemory | +|---|---|---| +| Ölçek | 200 satır sınırı | Sınırsız | +| Arama | Her şeyi bağlama yükler | BM25 + vektör + graf (yalnız top-K) | +| Token maliyeti | 240 gözlemde 22K+ | ~1,900 token (%92 daha az) | +| Ajanlar arası | Ajan başına dosya | MCP + REST (herhangi bir ajan) | +| Koordinasyon | Yok | Lease'ler, sinyaller, action'lar, routine'ler | +| Gözlemlenebilirlik | Dosyaları manuel okuma | :3113'te gerçek zamanlı görüntüleyici | + +--- + +

How It Works

+ +### Bellek Pipeline'ı + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4 Katmanlı Bellek Konsolidasyonu + +İnsan beyninin belleği nasıl işlediğinden ilham aldı — uyku konsolidasyonundan çok da farklı değil. + +| Katman | Ne | Analoji | +|------|------|---------| +| **Working** | Araç kullanımından ham gözlemler | Kısa süreli bellek | +| **Episodic** | Sıkıştırılmış oturum özetleri | "Ne oldu" | +| **Semantic** | Çıkarılmış olgular ve desenler | "Ne biliyorum" | +| **Procedural** | İş akışları ve karar desenleri | "Nasıl yapılır" | + +Bellekler zamanla decay olur (Ebbinghaus eğrisi). Sık erişilen bellekler güçlenir. Bayat bellekler otomatik tahliye edilir. Çelişkiler algılanır ve çözülür. + +### Ne Yakalanır + +| Hook | Yakalar | +|------|----------| +| `SessionStart` | Proje yolu, oturum ID | +| `UserPromptSubmit` | Kullanıcı istemleri (gizlilik-filtrelenmiş) | +| `PreToolUse` | Dosya erişim desenleri + zenginleştirilmiş bağlam | +| `PostToolUse` | Tool adı, girdi, çıktı | +| `PostToolUseFailure` | Hata bağlamı | +| `PreCompact` | Sıkıştırmadan önce belleği yeniden enjekte eder | +| `SubagentStart/Stop` | Alt-ajan yaşam döngüsü | +| `Stop` | Oturum sonu özeti | +| `SessionEnd` | Oturum tamamlandı işareti | + +### Temel Yetenekler + +| Yetenek | Açıklama | +|---|---| +| **Otomatik yakalama** | Her tool kullanımı hook'lar üzerinden kaydedilir — sıfır manuel çaba | +| **Anlamsal arama** | RRF füzyonu ile BM25 + vektör + bilgi grafı | +| **Bellek evrimi** | Sürümleme, supersede, ilişki grafları | +| **Otomatik unutma** | TTL süresi dolması, çelişki algılama, önem tahliyesi | +| **Gizlilik öncelikli** | API anahtarları, secret'lar, `` etiketleri depolamadan önce çıkarılır | +| **Kendini iyileştirme** | Devre kesici, sağlayıcı yedek zinciri, sağlık izleme | +| **Claude bridge** | MEMORY.md ile çift yönlü sync | +| **Bilgi grafı** | Entity çıkarımı + BFS gezinti | +| **Takım belleği** | İsimlendirilmiş paylaşımlı + özel takım üyeleri arasında | +| **Atıf provenansı** | Herhangi bir belleği kaynak gözlemlere kadar izleyin | +| **Git snapshot'ları** | Bellek durumunu sürümleyin, geri alın ve diff'leyin | + +--- + + + +Üç sinyali birleştiren üçlü-akış geri getirme: + +| Akış | Ne yapar | Ne zaman | +|---|---|---| +| **BM25** | Eş anlamlı genişletmeli stem'lenmiş anahtar kelime eşleştirme | Her zaman açık | +| **Vektör** | Yoğun embedding'ler üzerinde kosinüs benzerliği | Embedding sağlayıcı yapılandırılmış | +| **Graf** | Entity eşleştirme yoluyla bilgi grafı gezintisi | Sorguda entity'ler tespit edildiğinde | + +Reciprocal Rank Fusion (RRF, k=60) ile birleştirilir ve oturum-çeşitlendirilir (oturum başına maksimum 3 sonuç). + +BM25, Yunanca, Kiril, İbranice, Arapça ve aksanlı Latin'i kutudan çıkar çıkmaz tokenize eder. Çince / Japonca / Korece bellekler için, CJK akışlarını kelime-seviyesinde token'lara bölmek üzere isteğe bağlı segmenter'ları kurun (`npm install @node-rs/jieba tiny-segmenter`); bunlar olmadan agentmemory yumuşak olarak tüm-akış tokenizasyonuna düşer ve stderr'e bir kerelik bir ipucu yazdırır. + +### Embedding sağlayıcıları + +agentmemory sağlayıcınızı otomatik algılar. En iyi sonuçlar için yerel embedding'leri kurun (ücretsiz): + +```bash +npm install @xenova/transformers +``` + +| Sağlayıcı | Model | Maliyet | Notlar | +|---|---|---|---| +| **Yerel (önerilen)** | `all-MiniLM-L6-v2` | Ücretsiz | Çevrim dışı, BM25-only üzerinde +8pp recall | +| Gemini | `gemini-embedding-001` | Ücretsiz katman | 100+ dil, 768/1536/3072 boyut (MRL), 2048-token girdi. `text-embedding-004`'ün yerini alır ([deprecated, 14 Ocak 2026'da kapanış](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | 1M başı $0.02 | En yüksek kalite | +| Voyage AI | `voyage-code-3` | Ücretli | Kod için optimize edilmiş | +| Cohere | `embed-english-v3.0` | Ücretsiz deneme | Genel amaçlı | +| OpenRouter | Herhangi bir model | Değişken | Çok-modelli proxy | + +--- + +

MCP Server

+ +53 tool, 6 kaynak, 3 prompt ve 4 skill — herhangi bir ajan için en kapsamlı MCP bellek toolkit'i. + +> **MCP shim vs tam sunucu:** yayımlanan `@agentmemory/mcp` paketi ince bir shim'dir. Tam 51-tool yüzeyini **yalnızca `AGENTMEMORY_URL` üzerinden çalışan bir agentmemory sunucusuna erişebildiğinde** açığa çıkarır (proxy modu). Erişilebilir sunucu yoksa, shim 7-tool yerel sete (`memory_save`, `memory_recall`, `memory_smart_search`, `memory_sessions`, `memory_export`, `memory_audit`, `memory_governance_delete`) düşer. `AGENTMEMORY_TOOLS=core|all` env değişkeni *sunucu tarafı* bir bayraktır — shim'in `env` bloğunda ayarlamak hiçbir etki yapmaz. Cursor / OpenCode / Gemini CLI'da yalnızca 7 tool görüyorsanız, `npx @agentmemory/agentmemory` (veya Docker stack'i) başlatın ve `AGENTMEMORY_URL=http://localhost:3111` ayarlayın. + +### 51 Tool + +
+Çekirdek tool'lar (her zaman kullanılabilir) + +| Tool | Açıklama | +|------|-------------| +| `memory_recall` | Geçmiş gözlemleri ara | +| `memory_compress_file` | Yapıyı koruyarak markdown dosyalarını sıkıştır | +| `memory_save` | Bir içgörü, karar veya deseni kaydet | +| `memory_patterns` | Tekrar eden desenleri algıla | +| `memory_smart_search` | Hibrit anlamsal + anahtar kelime araması | +| `memory_file_history` | Belirli dosyalar hakkında geçmiş gözlemler | +| `memory_sessions` | Son oturumları listele | +| `memory_timeline` | Kronolojik gözlemler | +| `memory_profile` | Proje profili (kavramlar, dosyalar, desenler) | +| `memory_export` | Tüm bellek verisini dışa aktar | +| `memory_relations` | İlişki grafını sorgula | + +
+ +
+Genişletilmiş tool'lar (51 toplam — AGENTMEMORY_TOOLS=all ayarla) + +| Tool | Açıklama | +|------|-------------| +| `memory_patterns` | Tekrar eden desenleri algıla | +| `memory_timeline` | Kronolojik gözlemler | +| `memory_relations` | İlişki grafını sorgula | +| `memory_graph_query` | Bilgi grafı gezintisi | +| `memory_consolidate` | 4 katmanlı konsolidasyonu çalıştır | +| `memory_claude_bridge_sync` | MEMORY.md ile sync | +| `memory_team_share` | Takım üyeleriyle paylaş | +| `memory_team_feed` | Son paylaşılan öğeler | +| `memory_audit` | İşlemlerin denetim izi | +| `memory_governance_delete` | Denetim izi ile sil | +| `memory_snapshot_create` | Git-sürümlü snapshot | +| `memory_action_create` | Bağımlılıklarla iş öğesi oluştur | +| `memory_action_update` | Action durumunu güncelle | +| `memory_frontier` | Önceliğe göre sıralanmış engellenmemiş action'lar | +| `memory_next` | Tek en önemli sonraki action | +| `memory_lease` | Exclusive action lease'leri (çoklu ajan) | +| `memory_routine_run` | İş akışı routine'lerini örnekle | +| `memory_signal_send` | Ajanlar arası mesajlaşma | +| `memory_signal_read` | Onay alındıyla mesajları oku | +| `memory_checkpoint` | Harici koşul kapıları | +| `memory_mesh_sync` | Örnekler arasında P2P sync | +| `memory_sentinel_create` | Event-driven gözcüler | +| `memory_sentinel_trigger` | Sentinel'leri harici olarak tetikle | +| `memory_sketch_create` | Ephemeral action grafları | +| `memory_sketch_promote` | Kalıcıya yükselt | +| `memory_crystallize` | Action zincirlerini kompakt et | +| `memory_diagnose` | Sağlık kontrolleri | +| `memory_heal` | Sıkışmış durumu otomatik düzelt | +| `memory_facet_tag` | Boyut:değer etiketleri | +| `memory_facet_query` | Facet etiketlerine göre sorgula | +| `memory_verify` | Provenansı izle | + +
+ +### 6 Kaynak · 3 Prompt · 4 Skill + +| Tür | İsim | Açıklama | +|------|------|-------------| +| Resource | `agentmemory://status` | Sağlık, oturum sayısı, bellek sayısı | +| Resource | `agentmemory://project/{name}/profile` | Proje başına zeka | +| Resource | `agentmemory://memories/latest` | En son 10 aktif bellek | +| Resource | `agentmemory://graph/stats` | Bilgi grafı istatistikleri | +| Prompt | `recall_context` | Ara + bağlam mesajları döndür | +| Prompt | `session_handoff` | Ajanlar arasında handoff verisi | +| Prompt | `detect_patterns` | Tekrar eden desenleri analiz et | +| Skill | `/recall` | Belleği ara | +| Skill | `/remember` | Uzun süreli belleğe kaydet | +| Skill | `/session-history` | Son oturum özetleri | +| Skill | `/forget` | Gözlemleri/oturumları sil | + +### Bağımsız MCP + +Tam sunucu olmadan çalıştır — herhangi bir MCP istemcisi için. Şunlardan herhangi biri çalışır: + +```bash +npx -y @agentmemory/agentmemory mcp # kanonik (her zaman kullanılabilir) +npx -y @agentmemory/mcp # shim paketi takma adı +``` + +Veya ajanınızın MCP yapılandırmasına ekleyin: + +Çoğu ajan (Cursor, Claude Desktop, Cline, Roo Code, Windsurf, Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +`agentmemory` girdisini, dosyayı değiştirmek yerine host'unuzun mevcut `mcpServers` nesnesine birleştirin. Host'un `localhost`'una erişemeyen sandbox'lı istemciler için env bloğuna `"AGENTMEMORY_FORCE_PROXY": "1"` ekleyin ve `AGENTMEMORY_URL`'i sandbox'ın erişebileceği bir rotaya ayarlayın. + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Eklenti dosyasını depodan kopyalayın: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Real-Time Viewer

+ +`3113` portunda otomatik başlar. Canlı gözlem akışı, oturum gezgini, bellek tarayıcısı, bilgi grafı görselleştirmesi ve sağlık paneli. + +```bash +open http://localhost:3113 +``` + +Görüntüleyici sunucusu varsayılan olarak `127.0.0.1`'e bağlanır. REST-servisli `/agentmemory/viewer` endpoint'i normal `AGENTMEMORY_SECRET` bearer-token kurallarını izler. CSP başlıkları yanıt başına bir script nonce'ı kullanır ve satır içi handler özniteliklerini devre dışı bırakır (`script-src-attr 'none'`). + +--- + +

iii Console

+ +`:3113`'teki görüntüleyici ajanınızın **hatırladıklarını** gösterir. [iii konsolu](https://iii.dev/docs/console) ajanınızın **yaptıklarını** gösterir — her bellek op'u bir OpenTelemetry trace'i olarak, her KV girdisi düzenlenebilir, her fonksiyon çağrılabilir, her stream dinlenebilir. Aynı belleğe iki pencere: biri ürün-şekilli, diğeri motor-şekilli. + +Bir `memory_smart_search`'ün ateşlenmesini izleyin ve BM25 taramasını → embedding aramasını → RRF füzyonunu → reranker'ı bir şelale olarak görün. Sıkışmış bir konsolidasyon zamanlayıcısını KV tarayıcıda düzenleyin. `PostToolUse` hook'unu değiştirilmiş bir payload ile tekrar oynatın. WebSocket stream'ini sabitleyin ve gözlemlerin canlı olarak indiğini izleyin. + +agentmemory bunu ücretsiz dağıtır çünkü her fonksiyon, trigger, durum kapsamı ve stream bir iii primitif'idir — özel hiçbir şey, enstrümante edilecek hiçbir şey yok. + +

+ iii console Workers page — connected workers including agentmemory instances with live function counts and runtime metadata +
+ Workers sayfası: bağlı her worker — agentmemory'nin kendisi dahil — PID, fonksiyon sayısı, runtime ve son-görülme ile birlikte. +

+ +**Zaten kurulu.** Konsol `iii` ile birlikte gelir — ayrı kurucu yok. + +**agentmemory ile birlikte başlat:** + +```bash +# agentmemory görüntüleyicisi 3113 portunu tutar, bu yüzden konsolu 3114'te çalıştırın. +# Engine REST (3111), WebSocket (3112) ve bridge (49134) varsayılanları agentmemory ile eşleşir. +iii console --port 3114 +``` + +Ardından `http://localhost:3114`'ü açın. Deneysel mimari-grafı sayfası için `--enable-flow` ekleyin. + +Yalnızca taşıdıysanız engine endpoint'lerini override edin: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**Konsoldan neler yapabilirsiniz:** + +| Sayfa | Şunun için kullanın | +|------|-----------| +| **Workers** | Bağlı her worker'ı ve canlı metriklerini görün — agentmemory worker'ının kendisi dahil. | +| **Functions** | agentmemory'nin herhangi bir fonksiyonunu doğrudan JSON payload ile çağırın — bir istemci bağlamadan `memory.recall`, `memory.consolidate`, `graph.query` test etmek için kullanışlı. | +| **Triggers** | HTTP, cron, event ve state trigger'larını tekrar oynatın — konsolidasyon cron'unu manuel olarak ateşleyin, bir HTTP route'unu yeniden deneyin, bir durum değişikliği yayın. | +| **States** | Tam CRUD ile KV tarayıcı — oturumlar, bellek slot'ları, yaşam döngüsü zamanlayıcıları, embedding'ler indeksi — değerleri yerinde düzenleyin. | +| **Streams** | Bellek yazımları, hook olayları ve gözlem güncellemeleri için iii stream'leri üzerinden akarken canlı WebSocket monitörü. | +| **Queues** | Dayanıklı kuyruk konuları + dead-letter yönetimi. Başarısız embedding / sıkıştırma işlerini tekrar oynatın veya bırakın. | +| **Traces** | OpenTelemetry şelale / alev / hizmet-dağılımı görünümleri. `trace_id` ile filtreleyerek tek bir `memory.search`'ün hangi fonksiyonları, DB çağrılarını ve embedding isteklerini ürettiğini tam olarak görün. | +| **Logs** | Trace/span ID'lerine korelasyonlu, yapılandırılmış OTEL logları. | +| **Config** | Runtime yapılandırması — engine'inizin hangi worker'lar, sağlayıcılar ve portlarla çalıştığını tam olarak görün. | +| **Flow** | (İsteğe bağlı, `--enable-flow`) Her worker, trigger ve stream'in interaktif mimari grafı. | + +

+ iii console trace waterfall view showing per-span duration +
+ Traces: her bellek işlemi için şelale / alev / hizmet dağılımı. +

+ +**Trace'ler zaten açık:** + +`iii-config.yaml` `iii-observability` worker'ı etkinleştirilmiş olarak gelir (`exporter: memory`, `sampling_ratio: 1.0`, metrikler + log'lar). Ekstra yapılandırma gerekmez — agentmemory başlar başlamaz, her bellek işlemi konsolun okuyabileceği bir trace span'ı ve yapılandırılmış bir log yayar. + +Bunun yerine Jaeger/Honeycomb/Grafana Tempo'ya dışa aktarmak isterseniz, `exporter: memory`'yi `exporter: otlp` olarak değiştirin ve collector endpoint'ini iii'nin observability dokümanlarına göre ayarlayın. + +> **Dikkat:** konsolun kendisinde hiçbir auth zorlanmaz — `127.0.0.1`'e bağlı tutun (varsayılan) ve asla genel kullanıma açmayın. + +--- + +

Powered by iii

+ +agentmemory **zaten çalışan bir [iii](https://iii.dev) örneğidir**. Fonksiyonlar, trigger'lar, KV state, stream'ler, OTEL trace'leri — hepsi iii primitifleridir. Postgres, Redis, Express, pm2 veya Prometheus kurmadınız çünkü iii bunların yerini alıyor. + +Bu da, tek bir komutun agentmemory'yi tamamen yeni bir yetenekle genişlettiği anlamına gelir. + +### agentmemory'yi tek komutla genişletin + +```bash +iii worker add iii-pubsub # bellek yazımlarını bağlı her örneğe fan-out et +iii worker add iii-cron # zamanlanmış konsolidasyon, decay süpürmeleri, snapshot rotasyonu +iii worker add iii-queue # embedding + sıkıştırma işleri için dayanıklı yeniden denemeler +iii worker add iii-observability # her bellek op'unda OTEL trace'leri (varsayılan açık) +iii worker add iii-sandbox # hatırlanan kodu izole bir microVM içinde çalıştır +iii worker add iii-database # SQL destekli bir state adaptörü tak +iii worker add mcp # agentmemory MCP'sinin yanında genel MCP host'u +``` + +Her `iii worker add` agentmemory'nin zaten çalıştığı aynı engine'e yeni fonksiyonlar ve trigger'lar kaydeder. Görüntüleyici ve konsol bunları anında alır — yeniden yükleme yok, yeni entegrasyon yok, yeni container yok. + +| `iii worker add` | agentmemory'nin üzerine ne elde edersiniz | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | Çoklu-örnek bellek: her `remember` fan-out olur, her `search` birleşimi okur | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | Zamanlanmış yaşam döngüsü — geceleri konsolidasyon, haftalık snapshot'lar, sabit bir saatte decay | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | Dayanıklı yeniden denemeler: başarısız embedding + sıkıştırma işleri yeniden başlatmaya dayanır, kayıp gözlem yok | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | OTEL trace'leri, metrikleri, log'ları her fonksiyonda — birinci günden itibaren `iii-config.yaml`'da bağlı | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | `memory_recall`'dan çıkan kod, shell'inizde değil, bir kullan-at VM içinde çalışır | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | In-memory KV varsayılanlarını aştığınızda SQL destekli state adaptörü | +| [`mcp`](https://workers.iii.dev/workers/mcp) | agentmemory'ninin yanında ekstra MCP sunucuları ayağa kaldırın, aynı engine'i paylaşın | + +Tam kayıt defteri: [workers.iii.dev](https://workers.iii.dev). Oradaki her worker, agentmemory'nin kullandığı aynı primitifler aracılığıyla bir araya gelir — ve elinizde olan agentmemory de onlardan biridir. + +### iii'nin yerini aldığı şeyler + +| Geleneksel stack | agentmemory kullanır | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + in-memory vektör indeksi | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | iii engine worker süpervizyonu | +| Prometheus / Grafana | iii OTEL + sağlık monitörü | +| Özel eklenti sistemleri | `iii worker add ` | + +**118 kaynak dosya · ~21,800 LOC · 950+ test · 123 fonksiyon · 34 KV scope** — hepsi üç primitif üzerinde. `agentmemory plugin install` yok. Eklenti sistemi iii'nin kendisi. + +--- + +

Configuration

+ +### LLM Sağlayıcıları + +agentmemory ortamınızdan otomatik algılar. Varsayılan olarak, bir sağlayıcı yapılandırmadıkça veya Claude abonelik fallback'ine açıkça opt-in yapmadıkça hiçbir LLM çağrısı yapılmaz. + +| Sağlayıcı | Yapılandırma | Notlar | +|----------|--------|-------| +| **No-op (varsayılan)** | Yapılandırma gerekmez | LLM destekli compress/summarize DEVRE DIŞI. Sentetik BM25 sıkıştırma + recall hâlâ çalışır. Eskiden Claude-abonelik fallback'ine dayanıyorsanız aşağıdaki `AGENTMEMORY_ALLOW_AGENT_SDK`'ya bakın. | +| Anthropic API | `ANTHROPIC_API_KEY` | Token başına faturalama | +| MiniMax | `MINIMAX_API_KEY` | Anthropic-uyumlu | +| Gemini | `GEMINI_API_KEY` | Embedding'leri de etkinleştirir | +| OpenRouter | `OPENROUTER_API_KEY` | Herhangi bir model | +| Claude abonelik fallback'i | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | Yalnızca opt-in. `@anthropic-ai/claude-agent-sdk` oturumları doğurur — eskiden sınırsız Stop-hook recursion'ına neden oluyordu, bu yüzden artık varsayılan değil. | + +### Maliyet bilincine sahip model seçimi + +Arka plan sıkıştırması her gözlemde çalışır, bu yüzden model seçimi aylık harcamayı anlamlı ölçüde değiştirir. Yakalanan iş yükü verisi: 635 istek / 888K token / 35 saatlik aktif kullanım, 2026-05-23 fiyatlandırmasında üç OpenRouter modeline karşı çalıştırıldı. + +| Katman | Model | Girdi / 1M | Çıktı / 1M | Yakalanan 35 saat maliyeti | Notlar | +|------|-------|------------|-------------|---------------------------|-------| +| Önerilen | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | Sonnet'ten ~10× daha düşük maliyetle sağlam sıkıştırma + özetleme kalitesi. | +| Önerilen | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | Daha eski ama yalnızca-sıkıştırma iş yükleri için hâlâ iyi. | +| Önerilen | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | Oturumlarınız yoğun olarak kod-şekilli ise güçlü kod muhakemesi. | +| Premium | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | Yüksek kalite ancak her zaman açık arka plan çalışması için pahalı. | +| Premium | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | Sonnet ile benzer katman. | +| Kaçının | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | Reasoning sınıfı model; sıkıştırma için büyük aşırı harcama. | + +`OPENROUTER_MODEL` premium-katman bir desenle eşleştiğinde agentmemory bir runtime uyarısı yazdırır. Bilinçli bir seçim yaptıktan sonra susturmak için `AGENTMEMORY_SUPPRESS_COST_WARNING=1` ayarlayın. + +Bellek işi için kalite vs maliyet ödünleşmesi: sıkıştırma görece gevşek kalite çıtaları olan bir özetleme görevidir (özeti tekrar okuyan kullanıcı değil, ajandır). DeepSeek-V4-Pro / Qwen3-Coder bu görevde Sonnet'in yuvarlama hatası içinde kalırken ~10× daha az maliyetlidir. Premium katman modelleri doğrudan okuduğunuz sorgular için saklayın. + +Kaynaklar: [Sonnet 4.6 için OpenRouter fiyatlandırması](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing), [DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro), [DeepSeek fiyatlandırma notları](https://api-docs.deepseek.com/quick_start/pricing/). + +### Çoklu-ajan belleği (`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +Birden fazla rolün tek bir agentmemory sunucusunu paylaştığı çoklu-ajan kurulumlarında (architect / developer / reviewer / researcher / support-agent), `AGENT_ID` her yazıyı onu yapan rolle etiketler. `AGENTMEMORY_AGENT_SCOPE` recall'un bu etikete göre filtreleyip filtrelemeyeceğini kontrol eder. + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # isteğe bağlı; varsayılan "shared" +``` + +İki mod: + +| Mod | Yazıları etiketle | Recall'u filtrele | Ne zaman kullanılır | +|------|------------|---------------|-------------| +| `shared` (varsayılan) | evet | hayır | Denetim iziyle ajanlar arası bağlam. Architect, developer'ın notlarını görebilir ancak her satır kim söylediğini kaydeder. | +| `isolated` | evet | evet | Katı ayrım. Architect developer'ın gözlemlerini / belleklerini / oturumlarını asla görmez. | + +`AGENT_ID` ayarlandığında ne etiketlenir: `Session.agentId`, `RawObservation.agentId`, `CompressedObservation.agentId`, `Memory.agentId`. Rol `api::session::start` → `mem::observe` → `mem::compress` → KV boyunca akar. + +İzole modda ne filtrelenir: `mem::smart-search`, `/agentmemory/memories`, `/agentmemory/observations`, `/agentmemory/sessions`. Her endpoint istek başına override için `?agentId=` ve env kapsamından opt-out etmek için `?agentId=*` kabul eder. `/memories` ayrıca `agentId`'si undefined olan AGENT_ID öncesi belleklerin yüzeylenmesi için `?includeOrphans=true` kabul eder. + +SDK / REST katmanında çağrı başına override: her mutasyon endpoint'i (`/session/start`, `/remember`) istek gövdesinde env'i geçen bir `agentId` alanı kabul eder. Tek bir sunucu sürecinden birçok rolü yönlendiren runtime'lar için kullanışlıdır. + +`AGENT_ID` ayarlanmadığında bellek kapsam dışı kalır (eski davranış, etiket yok, filtre yok). + +### Portlar + +agentmemory + iii-engine varsayılan olarak dört port'a bağlanır. Bir yeniden başlatma `port in use` ile başarısız olursa, bu tablo hangi süreci aramanız gerektiğini söyler. + +| Port | Süreç | Amaç | Env override | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | Dahili stream'ler worker'ı (agentmemory + görüntüleyici tarafından tüketilir) | `III_STREAMS_PORT` | +| `3113` | agentmemory | Gerçek zamanlı görüntüleyici (`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — worker'lar burada kaydolur, OTel telemetri buradan akar | `III_ENGINE_URL` (tam URL, varsayılan `ws://localhost:49134`) | + +Çöken bir çalıştırma sonrası portlar bağlı kaldığında bayat-süreç temizliği: + +```bash +# macOS / Linux — her portta ne varsa bulun ve öldürün +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` graceful shutdown'da hem worker hem de engine pidfile'ını temiz olarak biçer. Yukarıdaki manuel temizlik yalnızca her iki pidfile'ın da geride kalmadığı çökme sonrası durum içindir. + +### Yapılandırma Dosyası + +agentmemory runtime yapılandırmasını her shell'de değişkenleri export etmek yerine `~/.agentmemory/.env`'a koyun. Görüntüleyici `export ANTHROPIC_API_KEY=...` gibi bir kurulum ipucu gösteriyorsa, `export` ön ekini koymadan bu dosyaya `ANTHROPIC_API_KEY=...` olarak kopyalayın, ardından agentmemory'yi yeniden başlatın. + +Süreç ortam değişkenleri hâlâ çalışır ve dosyadaki değerlere göre öncelik kazanır. + +Windows'ta aynı dosya `%USERPROFILE%\.agentmemory\.env` konumunda bulunur: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +API anahtarı yerine Claude Code Pro/Max aboneliğiyle test etmek için açıkça opt-in yapın: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +Graph veya konsolidasyon özelliklerini istiyorsanız aynı dosyada açın: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### Ortam Değişkenleri + +`~/.agentmemory/.env` oluşturun: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +`3111` portunda 124 endpoint. REST API varsayılan olarak `127.0.0.1`'e bağlanır. `AGENTMEMORY_SECRET` ayarlandığında korumalı endpoint'ler `Authorization: Bearer ` gerektirir ve mesh sync endpoint'leri her iki eşte de `AGENTMEMORY_SECRET` gerektirir. + +
+Önemli endpoint'ler + +| Method | Path | Açıklama | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | Sağlık kontrolü (her zaman public) | +| `POST` | `/agentmemory/session/start` | Oturum başlat + bağlam al | +| `POST` | `/agentmemory/session/end` | Oturumu bitir | +| `POST` | `/agentmemory/observe` | Gözlem yakala | +| `POST` | `/agentmemory/smart-search` | Hibrit arama | +| `POST` | `/agentmemory/context` | Bağlam üret | +| `POST` | `/agentmemory/remember` | Uzun süreli belleğe kaydet | +| `POST` | `/agentmemory/forget` | Gözlemleri sil | +| `POST` | `/agentmemory/enrich` | Dosya bağlamı + bellekler + bug'lar | +| `GET` | `/agentmemory/profile` | Proje profili | +| `GET` | `/agentmemory/export` | Tüm veriyi dışa aktar | +| `POST` | `/agentmemory/import` | JSON'dan içeri aktar | +| `POST` | `/agentmemory/graph/query` | Bilgi grafı sorgusu | +| `POST` | `/agentmemory/team/share` | Takımla paylaş | +| `GET` | `/agentmemory/audit` | Denetim izi | + +Tam endpoint listesi: [`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Development

+ +```bash +npm run dev # Hot reload +npm run build # Production build +npm test # 950+ test +npm run test:integration # API testleri (çalışan servisler gerektirir) +``` + +**Ön koşullar:** Node.js >= 20, [iii-engine](https://iii.dev/docs) veya Docker + +

License

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.zh-CN.md b/READMEs/README.zh-CN.md new file mode 100644 index 0000000..e711a36 --- /dev/null +++ b/READMEs/README.zh-CN.md @@ -0,0 +1,1377 @@ +

+ agentmemory — 为 AI 编码代理提供持久化记忆 +

+ +

+ + 让你的编码代理记住一切。不再重复解释。 + Built on iii engine +
+ 为 Claude Code、Cursor、Gemini CLI、Codex CLI、Hermes、OpenClaw、pi、OpenCode 以及任何 MCP 客户端提供持久化记忆。 +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Design doc: 1200 stars / 172 forks on the gist +

+ +

+ 这份 gist 以置信度评分、生命周期管理、知识图谱和混合搜索扩展了 Karpathy 的 LLM Wiki 模式:agentmemory 就是其实现。 +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ agentmemory demo +

+ +

+ 安装 • + 快速开始 • + 基准测试 • + 对比竞品 • + 代理 • + 工作原理 • + MCP • + 查看器 • + iii 控制台 • + 由 iii 驱动 • + 配置 • + API +

+ +--- + +## 安装 + +```bash +npm install -g @agentmemory/agentmemory # 一次安装 — 全局可用 `agentmemory` 命令 +# 如果在 macOS/Linux 的系统 Node 上遇到 EACCES,请重试: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # 在 :3111 启动记忆服务器 +agentmemory demo # 注入示例会话并验证召回 +agentmemory connect claude-code # 连接你的代理(也支持: codex, cursor, gemini-cli, ...) +``` + +或通过 `npx`(无需安装): + +```bash +npx @agentmemory/agentmemory +``` + +提醒 — npx 会按版本缓存。如果裸 `npx @agentmemory/agentmemory` 命令运行的是旧版本,强制使用最新版 `npx -y @agentmemory/agentmemory@latest`,或一次性清除缓存 `rm -rf ~/.npm/_npx`(macOS/Linux;Windows 上删除 `%LOCALAPPDATA%\npm-cache\_npx`)。从 v0.9.16+ 起,首次 npx 运行会内联提示你全局安装,这样之后裸 `agentmemory` 命令在任何地方都能用。 + +完整选项见下方[快速开始](#quick-start)。各代理具体接入见[支持所有代理](#works-with-every-agent)。 + +--- + +

Works with every agent

+ +agentmemory 兼容任何支持 hooks、MCP 或 REST API 的代理。所有代理共享同一个记忆服务器。 + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+原生插件 + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+原生插件 + 6 hooks + MCP +
+OpenClaw
+OpenClaw
+原生插件 + MCP +
+Hermes
+Hermes
+原生插件 + MCP +
+pi
+pi
+原生插件 + MCP +
+OpenHuman
+OpenHuman
+原生 Memory trait 后端 +
+Cursor
+Cursor
+MCP 服务器 +
+Gemini CLI
+Gemini CLI
+MCP 服务器 +
+OpenCode
+OpenCode
+22 hooks + MCP + 插件 +
+Cline
+Cline
+MCP 服务器 +
+Goose
+Goose
+MCP 服务器 +
+Kilo Code
+Kilo Code
+MCP 服务器 +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP 服务器 +
+Windsurf
+Windsurf
+MCP 服务器 +
+Roo Code
+Roo Code
+MCP 服务器 +
+ +

+ 兼容任何使用 MCP 或 HTTP 的代理。一个服务器,所有代理共享记忆。 +

+ +--- + +你每次会话都在重复解释同样的架构。你反复发现同样的 bug。你重复教同样的偏好。内建的记忆(CLAUDE.md、.cursorrules)上限是 200 行而且会过时。agentmemory 解决了这个问题。它在后台静默捕获代理的行为,将其压缩为可搜索的记忆,并在下次会话开始时注入正确的上下文。一条命令。跨代理工作。 + +**改变了什么:** 会话 1 你设置了 JWT 鉴权。会话 2 你要求限流。代理已经知道你的鉴权使用 `src/middleware/auth.ts` 中的 jose 中间件,测试覆盖了 token 校验,你选择 jose 而非 jsonwebtoken 是为了 Edge 兼容性。无需重新解释。无需复制粘贴。代理就是*知道*。 + +```bash +npx @agentmemory/agentmemory +``` + +> **v0.9.0 新功能** — 落地页 [agent-memory.dev](https://agent-memory.dev) 上线,文件系统连接器(`@agentmemory/fs-watcher`),独立 MCP 现在代理至正在运行的服务器,使 hooks 和查看器保持一致,审计策略在所有删除路径上得到统一,健康状态在小型 Node 进程上不再误报 `memory_critical`。完整变更见 [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18)。 + +--- + +

Benchmarks

+ + + + + + +
+ +### 检索准确率 + +**coding-agent-life-v1** (内部语料库,沙盒可复现) + +| 适配器 | P@5 | R@5 | Top-5 命中率 | p50 延迟 | +|---|---|---|---|---| +| **agentmemory 混合** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep 基线 | 0.267 | 0.967 | 15 / 15 | 0 ms | + +100% Top-5 命中率。在同一输入下,精度比 grep 基线高 **2.2×**。完整按类型分解:[`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 个问题) + +| 系统 | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| 仅 BM25 回退 | 86.2% | 94.6% | 71.5% | + + + +### Token 节省 + +| 方法 | Token/年 | 成本/年 | +|---|---|---| +| 粘贴全部上下文 | 19.5M+ | 不可能(超出窗口) | +| LLM 摘要 | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + 本地嵌入 | ~170K | **$0** | + +
+ +> 嵌入模型:`all-MiniLM-L6-v2` (本地、免费、无需 API key)。完整报告:[`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md)、[`benchmark/QUALITY.md`](../benchmark/QUALITY.md)、[`benchmark/SCALE.md`](../benchmark/SCALE.md)。竞品对比:[`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory 对比 mem0、Letta、Khoj、claude-mem、Hippo。 + +**本地复现:** [`eval/README.md`](../eval/README.md) — 适配器可插拔的 harness,支持 LongMemEval `_s`(公开 500 问)+ `coding-agent-life-v1`(内部 15 会话语料)。Grep / 向量 / agentmemory 适配器并排打分,NDJSON 输出,公开记分卡发布于 [`docs/benchmarks/`](../docs/benchmarks/)。 + +**搭配 [codegraph](https://github.com/colbymchenry/codegraph)、[Understand Anything](https://github.com/Lum1104/Understand-Anything) 和 [Graphify](https://github.com/safishamsi/graphify) 使用。** 代码图索引、多代理构建流水线,以及跨文档 / PDF / 图像 / 视频的更广泛知识图谱。agentmemory 记住工作内容;这三个项目点亮上下文层的其余部分。组合配方和问题路由表:[`docs/recipes/pairings.md`](../docs/recipes/pairings.md)。 + +--- + +

vs Competitors

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)内建 (CLAUDE.md)
类型记忆引擎 + MCP 服务器记忆层 API完整代理运行时静态文件
检索 R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/A (grep)
自动捕获12 hooks (零人工)手动调用 add()代理自编辑手动编辑
搜索BM25 + 向量 + 图 (RRF 融合)向量 + 图向量 (归档)将所有内容加载到上下文
多代理MCP + REST + 租约 + 信号API (无协调)仅在 Letta 运行时内部每代理一个文件
框架锁定无 (任何 MCP 客户端)高 (必须使用 Letta)每代理格式
外部依赖无 (SQLite + iii-engine)Qdrant / pgvectorPostgres + 向量数据库
记忆生命周期4 层整合 + 衰减 + 自动遗忘被动提取代理管理手动清理
Token 效率~1,900 tokens/会话 ($10/年)依集成方式不同核心记忆位于上下文240 条观测达 22K+ tokens
实时查看器是 (端口 3113)云端仪表板云端仪表板
自托管是 (默认)可选可选
+ +--- + +

Quick Start

+ +兼容性:此版本面向稳定的 `iii-sdk` `^0.11.0` 和 iii-engine v0.11.x。 + +### 30 秒体验 + +```bash +# 终端 1:启动服务器 +npx @agentmemory/agentmemory + +# 终端 2:注入示例数据并查看召回 +npx @agentmemory/agentmemory demo +``` + +`demo` 会注入 3 个真实会话(JWT 鉴权、N+1 查询修复、限流)并对它们执行语义搜索。你将看到搜索「数据库性能优化」时找到「N+1 查询修复」 — 关键词匹配做不到这一点。 + +打开 `http://localhost:3113` 即时观察记忆的构建过程。 + +### 推荐:全局安装 + +`npx` 按版本缓存。如果你上周运行过 `npx @agentmemory/agentmemory@0.9.14`,裸 `npx @agentmemory/agentmemory` 命令可能会从 `~/.npm/_npx/` 提供过期的 0.9.14 而非最新版本。安装一次后,裸 `agentmemory` 命令处处可用: + +```bash +npm install -g @agentmemory/agentmemory +# 如果在 macOS/Linux 的系统 Node 上遇到 EACCES,请重试: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # 启动服务器(等同于 npx 形式) +agentmemory stop # 停止 +agentmemory remove # 卸载所有创建的内容 +agentmemory connect claude-code # 连接一个代理 +agentmemory doctor # 交互式诊断 + 修复提示 +``` + +从 v0.9.16 开始,首次 npx 运行会内联提示你全局安装 — 回答一次 `Y` 即可。如果你跳过,可使用以下任一方式获取最新版: + +```bash +npx -y @agentmemory/agentmemory@latest # 强制从 npm 拉取最新(跨平台) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # 仅 macOS/Linux (POSIX shell) +``` + +在 Windows / PowerShell 上,等价的缓存清除命令是 `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` — 上面的 `npx -y ...@latest` 形式是跨平台选项。 + +### 会话回放 + +agentmemory 记录的每个会话都可回放。打开查看器,选择 **Replay** 标签,在时间线上拖动:提示词、工具调用、工具结果和响应都作为离散事件呈现,支持播放/暂停、速度控制(0.5×–4×)和键盘快捷键(空格切换,箭头单步)。 + +已有旧的 Claude Code JSONL 记录想导入? + +```bash +# 导入默认 ~/.claude/projects 下的全部内容 +npx @agentmemory/agentmemory import-jsonl + +# 或导入单个文件 +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +导入的会话与原生会话一起出现在 Replay 选择器中。底层每个条目都通过 `mem::replay::load`、`mem::replay::sessions`、`mem::replay::import-jsonl` 这些 iii 函数路由 — 没有侧通道服务器。 + +### 升级 / 维护 + +当你确实想更新本地运行时时,使用维护命令: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +警告:此命令会变更当前工作空间/运行时。它可能更新 JavaScript 依赖,并拉取固定版本的 Docker 镜像 `iiidev/iii:0.11.2`。它绝不会安装未固定版本或更新的 iii 引擎。 + +实现细节见 `src/cli.ts`(参考 `src/cli.ts:544-595` 附近的 `runUpgrade`)。 + +### Claude Code(一段话,直接粘贴) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Claude Code 不安装插件(MCP-standalone 路径) + +如果你直接通过 `~/.claude.json` 连接 agentmemory 的 MCP 服务器而不使用 `/plugin install`,Claude Code 永远不会解析 `${CLAUDE_PLUGIN_ROOT}`,你必须把 hook 脚本指向 `~/.claude/settings.json` 中的绝对路径。这些路径通常会嵌入 agentmemory 版本号(例如 `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`),因此下次升级会静默破坏所有 hooks。 + +变通方法: + +```bash +agentmemory connect claude-code --with-hooks +``` + +这会将同样的 hook 命令合并到 `~/.claude/settings.json`,绝对路径解析到当前安装的 `@agentmemory/agentmemory` 包的 `plugin/` 目录。升级 agentmemory 后重新运行该命令以刷新路径。同一文件中的用户条目会被保留;只替换之前的 agentmemory 条目。仍然推荐使用 `/plugin install` 路径。 + +对于远程或受保护的部署,启动 Claude Code 时设置 `AGENTMEMORY_URL` 和 `AGENTMEMORY_SECRET`。插件会将两个值传递给其捆绑的 MCP 服务器;当 `AGENTMEMORY_URL` 为空时,MCP shim 默认使用 `http://localhost:3111`。 + +### Codex CLI(Codex 插件平台) + +```bash +# 1. 在单独终端启动记忆服务器 +npx @agentmemory/agentmemory + +# 2. 注册 agentmemory 市场并安装插件 +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Codex 插件与 Claude Code 插件同源,来自相同的 `plugin/` 目录。它注册: + +- `@agentmemory/mcp` 作为 MCP 服务器(当 `AGENTMEMORY_URL` 指向运行中的 agentmemory 服务器时,代理全部 51 个工具;若服务器不可达,本地回退至 7 个工具) +- 6 个生命周期 hooks:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`PreCompact`、`Stop` +- 4 个 skills:`/recall`、`/remember`、`/session-history`、`/forget` + +Codex 的 hook 引擎会将 `CLAUDE_PLUGIN_ROOT` 注入 hook 子进程(参见 [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)),因此同样的 hook 脚本在两个宿主中都能工作,无需重复实现。Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure 事件仅 Claude Code 支持,Codex 未注册这些。 + +#### Codex Desktop:插件 hooks 当前无响应(有变通方法) + +`CodexHooks` 和 `PluginHooks` 在 [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs) 中都已稳定且默认启用,但 Codex Desktop 当前不会派发插件本地的 `hooks.json`([openai/codex#16430](https://github.com/openai/codex/issues/16430))。MCP 工具仍能工作;只是生命周期观测缺失。 + +在上游修复落地前,将同样的 hook 命令镜像到全局 `~/.codex/hooks.json`: + +```bash +agentmemory connect codex --with-hooks +``` + +这会在 `~/.codex/hooks.json` 添加一个幂等块,引用捆绑脚本的绝对路径(用户级作用域下无需 `${CLAUDE_PLUGIN_ROOT}` 展开)。升级 agentmemory 后重新运行同一命令以刷新路径。同一文件中的用户条目会被保留;只替换之前的 agentmemory 条目。 + +
+OpenClaw(粘贴此提示) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +完整指南:[`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent(粘贴此提示) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +完整指南:[`integrations/hermes/`](../integrations/hermes/) + +
+ +### 其他代理 + +启动记忆服务器:`npx @agentmemory/agentmemory` + +在使用 `mcpServers` 结构的每个宿主(Cursor、Claude Desktop、Cline、Roo Code、Windsurf、Gemini CLI、OpenClaw)中,agentmemory 条目是**相同的 MCP 服务器块**: + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**将此条目合并到宿主配置文件的现有 `mcpServers` 对象中** — 不要替换整个文件。如果文件已经有其他服务器,把 `agentmemory` 作为另一个 key 加在它们旁边。如果完全缺少 `mcpServers`,把整块粘贴到 `{ "mcpServers": { ... } }` 里。`${VAR}` 占位符会在 MCP 服务器启动时从 shell 继承 `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` — 未设置的变量传空字符串,shim 回退到 `http://localhost:3111`。一个接好的条目同时覆盖本地和远程(k8s / 反代)部署。 + +| 代理 | 配置文件 | 备注 | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | 合并到 `mcpServers`。网站上也提供一键深链。 | +| **Claude Desktop** | `claude_desktop_config.json` (Application Support) | 合并到 `mcpServers`。编辑后重启 Claude Desktop。 | +| **Cline / Roo Code / Kilo Code** | Cline MCP 设置 (设置 UI → MCP Servers → Edit) | 同样的 `mcpServers` 块。 | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | 同样的 `mcpServers` 块。 | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user`(自动合并)。 | +| **OpenClaw** | OpenClaw MCP 配置 | 同样的 `mcpServers` 块,或使用更深的[记忆插件](../integrations/openclaw/)。 | +| **Codex CLI (仅 MCP)** | `.codex/config.toml` | TOML 形式:`codex mcp add agentmemory -- npx -y @agentmemory/mcp`,或手动添加 `[mcp_servers.agentmemory]`。 | +| **Codex CLI (完整插件)** | Codex 插件市场 | `codex plugin marketplace add rohitg00/agentmemory` 然后 `codex plugin add agentmemory@agentmemory`。注册 MCP + 6 个生命周期 hooks(SessionStart、UserPromptSubmit、PreToolUse、PostToolUse、PreCompact、Stop)+ 4 个 skills。在 Codex Desktop 上,直到 [openai/codex#16430](https://github.com/openai/codex/issues/16430) 落地之前,还要运行 `agentmemory connect codex --with-hooks` — 那里的插件 hooks 当前无响应。 | +| **OpenCode (仅 MCP)** | `opencode.json` | 不同结构 — 顶层 `mcp` key,command 是数组:`{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`。 | +| **OpenCode (完整插件)** | `plugin/opencode/` | 22 个自动捕获 hooks,覆盖会话生命周期、消息、工具、错误。两个斜杠命令(`/recall`、`/remember`)。将 `plugin/opencode/` 复制到你的 OpenCode 工作空间并把插件条目添加到 `opencode.json`。完整 hook 表和差异分析见 [`plugin/opencode/README.md`](../plugin/opencode/README.md)。 | +| **pi** | `~/.pi/agent/extensions/agentmemory` | 复制 [`integrations/pi`](../integrations/pi/) 并重启 pi。 | +| **Hermes Agent** | `~/.hermes/config.yaml` | 使用更深的[记忆提供者插件](../integrations/hermes/),设置 `memory.provider: agentmemory`。 | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` 会写入标准的 `mcpServers` 块。Hook 负载与 Claude Code 字段兼容,因此现有的 12 hook 脚本无需修改即可工作 — 通过同一 `settings.json` 的 `hooks` 段连接它们。 | +| **Antigravity** (替换 Gemini CLI) | `mcp_config.json`(在 Antigravity 的 User 目录中) | `agentmemory connect antigravity` 会写入标准的 `mcpServers` 块。macOS: `~/Library/Application Support/Antigravity/User/`。Linux: `~/.config/Antigravity/User/`。在 2026-06-18 Gemini CLI 停服后使用。 | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` 写入用户级配置。工作空间覆盖放在你的代码旁的 `.kiro/settings/mcp.json` 中。 | +| **Goose** | Goose MCP 设置 UI | 同样的 `mcpServers` 块。 | +| **Aider** | n/a | 直接调用 REST API:`curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`。 | +| **任何代理 (32+)** | n/a | `npx skillkit install agentmemory` 自动检测宿主并合并。 | + +**沙盒化的 MCP 客户端**(Flatpak / Snap / 受限容器)无法访问宿主的 `localhost`:还要在 `env` 块中设置 `"AGENTMEMORY_FORCE_PROXY": "1"`,并把 `AGENTMEMORY_URL` 指向沙盒确实能到达的路由(例如你的 LAN IP)。 + +### 程序化访问(Python / Rust / Node) + +agentmemory 将其核心操作注册为 iii 函数(`mem::remember`、`mem::observe`、`mem::context`、`mem::smart-search`、`mem::forget`)。任何拥有 iii SDK 的语言都可以通过 `ws://localhost:49134` 直接调用它们 — 无需为每种语言准备单独的 REST 客户端。 + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +完整示例:[`examples/python/`](../examples/python/)(快速开始 + 观测/召回流程)。`:3111` 上的 REST 对没有 iii 运行时的宿主仍然可用。 + +### 从源码构建 + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +如果已经安装 `iii`,这会以本地 `iii-engine` 启动 agentmemory;如果 Docker 可用,则回退到 Docker Compose。REST、流和查看器默认绑定到 `127.0.0.1`。 + +手动安装 `iii-engine`。**agentmemory 当前将 `iii-engine` 固定在 `v0.11.2`** — `v0.11.6` 引入了新的「通过 `iii worker add` 沙盒化一切」模型,agentmemory 尚未为此重构。重构落地后即解除固定。如果你已经手动迁移到沙盒模型,可用 `AGENTMEMORY_III_VERSION=` 覆盖。 + +- **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:** 把 `aarch64-apple-darwin` 换成 `x86_64-apple-darwin` +- **Linux x64:** 换成 `x86_64-unknown-linux-gnu` +- **Linux arm64:** 换成 `aarch64-unknown-linux-gnu` +- **Windows:** 从 [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2) 下载 `iii-x86_64-pc-windows-msvc.zip`,提取 `iii.exe`,加入 PATH + +或使用 Docker(捆绑的 `docker-compose.yml` 会拉取 `iiidev/iii:0.11.2`)。完整文档:[iii.dev/docs](https://iii.dev/docs)。 + +### Windows + +agentmemory 可在 Windows 10/11 运行,但仅 Node.js 包不够 — 你还需要 `iii-engine` 运行时(一个独立的原生二进制)作为后台进程。官方上游安装器是 `sh` 脚本,目前没有 PowerShell 安装器或 scoop/winget 包,因此 Windows 用户有两条路径: + +**选项 A — 预构建 Windows 二进制(推荐):** + +```powershell +# 1. 在浏览器打开 https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 +# (我们固定在 v0.11.2,直到 agentmemory 为 v0.11.6+ 引擎要求的 +# 新沙盒模型完成重构) +# 2. 下载 iii-x86_64-pc-windows-msvc.zip +# (如果是 ARM 机器则下载 iii-aarch64-pc-windows-msvc.zip) +# 3. 把 iii.exe 解压到 PATH 上的某处,或放在: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory 会自动检查该位置) +# 4. 验证: +iii --version +# 应输出:0.11.2 + +# 5. 然后照常运行 agentmemory: +npx -y @agentmemory/agentmemory +``` + +**选项 B — Docker Desktop:** + +```powershell +# 1. 安装 Docker Desktop for Windows +# 2. 启动 Docker Desktop 并确保引擎运行中 +# 3. 运行 agentmemory — 它会自动启动捆绑的 compose 文件: +npx -y @agentmemory/agentmemory +``` + +**选项 C — 仅独立 MCP(无引擎):** 如果你只需要 MCP 工具供代理使用,不需要 REST API、查看器或定时任务,则完全跳过引擎: + +```powershell +npx -y @agentmemory/agentmemory mcp +# 或通过 shim 包: +npx -y @agentmemory/mcp +``` + +**Windows 诊断:** 如果 `npx @agentmemory/agentmemory` 失败,加 `--verbose` 重新运行以看到实际的引擎 stderr。常见失败模式: + +| 症状 | 修复 | +|---|---| +| `iii-engine process started` 然后 `did not become ready within 15s` | 引擎启动崩溃 — 用 `--verbose` 重新运行,检查 stderr | +| `Could not start iii-engine` | `iii.exe` 和 Docker 都未安装。见上面选项 A 或 B | +| 端口冲突 | `netstat -ano \| findstr :3111` 查看占用,然后 kill 或用 `--port ` | +| Docker 已安装但仍跳过回退 | 确保 Docker Desktop 确实在运行(系统托盘图标) | + +> 注意:iii **引擎** 是预构建的二进制文件,而非 cargo crate — 不要尝试用 `cargo install` 安装它。(iii 的 **SDK** 确实已发布到 crates.io、npm 和 PyPI,但 agentmemory 并不需要它们。)受支持的引擎安装方式均固定为 v0.11.2:上面的预构建 v0.11.2 二进制、**带版本固定** 的上游 `sh` 安装脚本 `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh`(macOS/Linux),以及 Docker 镜像 `iiidev/iii:0.11.2`。直接运行 `install.sh | sh` 会安装 **最新** 引擎,而 agentmemory 不支持该版本 — 请务必传入 `VERSION=0.11.2`。最简单的方式:直接运行 `npx @agentmemory/agentmemory`,它会为你把固定版本的引擎获取到 `~/.agentmemory/bin`。 + +--- + +

部署

+ +托管主机的一键模板。每个模板都附带自包含的 +Dockerfile,从 npm 拉取 `@agentmemory/agentmemory` 并从官方 +`iiidev/iii` Docker Hub 镜像复制 iii 引擎二进制 — 无需 +预构建 agentmemory 镜像。持久存储挂载在 +`/data`;首次启动 entrypoint 用面向部署调优的配置 +覆盖 npm 捆绑的 iii 配置(原配置绑定 `127.0.0.1`), +让其绑定 `0.0.0.0` 并使用绝对 `/data` 路径,生成 +HMAC secret,然后通过 `gosu` 从 `root` 降权到 `node` +再 exec agentmemory CLI。 + +

+ Deploy to fly.io + Deploy to Railway +

+ +Render 的一键部署按钮要求仓库根有 `render.yaml`,我们刻意保持根目录整洁。使用 [`deploy/render/`](../deploy/render/README.md) 中文档化的 Render Blueprint 流程,手动指向仓库内的蓝图。 + +完整设置细节(HMAC 捕获、查看器 SSH 隧道、轮换、备份、 +成本下限)见 [`deploy/`](../deploy/README.md): + +- [`deploy/fly`](../deploy/fly/README.md) — 单机搭配 + `auto_stop_machines = "stop"`;空闲时最便宜。 +- [`deploy/railway`](../deploy/railway/README.md) — Hobby 套餐固定费用, + 卷在仪表板中配置。 +- [`deploy/render`](../deploy/render/README.md) — Blueprint 流程, + 付费套餐自动磁盘快照。 +- [`deploy/coolify`](../deploy/coolify/README.md) — 通过 [Coolify](https://coolify.io/self-hosted) + 在你自己的 VPS 上自托管;同样的 Docker + Compose 栈,主机和数据都归你所有。 + +只发布端口 `3111`。`3113` 上的查看器在容器内仍绑定到 +loopback — 每个模板的 README 都文档化了到达它的 +SSH 隧道模式。 + +--- + +

Why agentmemory

+ +每个编码代理在会话结束时都会忘记一切。你每次会话的前 5 分钟都浪费在重新解释技术栈上。agentmemory 在后台运行,完全消除这一点。 + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### 对比内建代理记忆 + +每个 AI 编码代理都自带内建记忆 — Claude Code 有 `MEMORY.md`,Cursor 有 notepad,Cline 有 memory bank。这些像便利贴。agentmemory 是便利贴背后的可搜索数据库。 + +| | 内建 (CLAUDE.md) | agentmemory | +|---|---|---| +| 规模 | 200 行上限 | 无限 | +| 搜索 | 把所有内容加载到上下文 | BM25 + 向量 + 图 (仅 top-K) | +| Token 成本 | 240 条观测达 22K+ | ~1,900 tokens(少 92%) | +| 跨代理 | 每代理一个文件 | MCP + REST(任何代理) | +| 协调 | 无 | 租约、信号、动作、例程 | +| 可观测性 | 手动读文件 | 端口 3113 实时查看器 | + +--- + +

How It Works

+ +### 记忆流水线 + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4 层记忆整合 + +灵感来自人脑处理记忆的方式 — 与睡眠时的记忆整合并无不同。 + +| 层级 | 内容 | 类比 | +|------|------|---------| +| **Working(工作记忆)** | 来自工具使用的原始观测 | 短期记忆 | +| **Episodic(情景记忆)** | 压缩后的会话摘要 | 「发生了什么」 | +| **Semantic(语义记忆)** | 提取的事实与模式 | 「我知道什么」 | +| **Procedural(程序记忆)** | 工作流与决策模式 | 「怎么做」 | + +记忆随时间衰减(Ebbinghaus 曲线)。频繁访问的记忆会强化。陈旧记忆会自动清除。矛盾会被检测并解决。 + +### 捕获了什么 + +| Hook | 捕获内容 | +|------|----------| +| `SessionStart` | 项目路径、会话 ID | +| `UserPromptSubmit` | 用户提示词(隐私过滤) | +| `PreToolUse` | 文件访问模式 + 富化上下文 | +| `PostToolUse` | 工具名、输入、输出 | +| `PostToolUseFailure` | 错误上下文 | +| `PreCompact` | 在压缩前重新注入记忆 | +| `SubagentStart/Stop` | 子代理生命周期 | +| `Stop` | 会话结束摘要 | +| `SessionEnd` | 会话完成标记 | + +### 关键能力 + +| 能力 | 描述 | +|---|---| +| **自动捕获** | 每次工具使用都通过 hooks 记录 — 零人工 | +| **语义搜索** | BM25 + 向量 + 知识图谱,RRF 融合 | +| **记忆演化** | 版本控制、覆盖关系、关系图 | +| **自动遗忘** | TTL 过期、矛盾检测、重要性驱逐 | +| **隐私优先** | API key、secret、`` 标签存储前被剥离 | +| **自愈** | 熔断器、提供者回退链、健康监控 | +| **Claude 桥接** | 与 MEMORY.md 双向同步 | +| **知识图谱** | 实体抽取 + BFS 遍历 | +| **团队记忆** | 团队成员之间的命名空间共享 + 私有 | +| **引用溯源** | 任意记忆追溯到源观测 | +| **Git 快照** | 记忆状态的版本、回滚、diff | + +--- + + + +三路检索结合三种信号: + +| 流 | 作用 | 何时启用 | +|---|---|---| +| **BM25** | 词干化关键词匹配 + 同义词扩展 | 始终启用 | +| **Vector(向量)** | 稠密嵌入上的余弦相似度 | 配置了嵌入提供者 | +| **Graph(图)** | 通过实体匹配进行知识图谱遍历 | 查询中检测到实体 | + +通过 Reciprocal Rank Fusion (RRF, k=60) 融合,并按会话多样化(每会话最多 3 个结果)。 + +BM25 开箱即用支持希腊语、西里尔语、希伯来语、阿拉伯语和带音标的拉丁文分词。对于中文/日语/韩语记忆,安装可选分词器(`npm install @node-rs/jieba tiny-segmenter`)以把 CJK 串切分为词级 token;不安装的话,agentmemory 会软回退到整串分词并在 stderr 打印一次性提示。 + +### 嵌入提供者 + +agentmemory 自动检测你的提供者。为获得最佳效果,安装本地嵌入(免费): + +```bash +npm install @xenova/transformers +``` + +| 提供者 | 模型 | 成本 | 备注 | +|---|---|---|---| +| **本地 (推荐)** | `all-MiniLM-L6-v2` | 免费 | 离线,比仅 BM25 召回率高 +8pp | +| Gemini | `gemini-embedding-001` | 免费层 | 100+ 语言,768/1536/3072 维 (MRL),2048-token 输入。替换 `text-embedding-004`([已弃用,2026 年 1 月 14 日下线](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | $0.02/1M | 最高质量 | +| Voyage AI | `voyage-code-3` | 付费 | 针对代码优化 | +| Cohere | `embed-english-v3.0` | 免费试用 | 通用 | +| OpenRouter | 任意模型 | 视而定 | 多模型代理 | + +--- + +

MCP Server

+ +53 个工具、6 个资源、3 个提示词、4 个 skills — 任何代理可用的最全面 MCP 记忆工具包。 + +> **MCP shim 对比完整服务器:** 已发布的 `@agentmemory/mcp` 包是一个薄 shim。**只有当它能通过 `AGENTMEMORY_URL` 连通运行中的 agentmemory 服务器**(代理模式)时,才暴露完整的 51 工具表面。在没有可达服务器的情况下,shim 回退到 7 工具的本地集合(`memory_save`、`memory_recall`、`memory_smart_search`、`memory_sessions`、`memory_export`、`memory_audit`、`memory_governance_delete`)。`AGENTMEMORY_TOOLS=core|all` 环境变量是*服务器端*标志 — 在 shim 的 `env` 块中设置无效。如果在 Cursor / OpenCode / Gemini CLI 中只看到 7 个工具,启动 `npx @agentmemory/agentmemory`(或 Docker 栈)并设置 `AGENTMEMORY_URL=http://localhost:3111`。 + +### 51 个工具 + +
+核心工具(始终可用) + +| 工具 | 描述 | +|------|-------------| +| `memory_recall` | 搜索过去的观测 | +| `memory_compress_file` | 在保留结构的同时压缩 markdown 文件 | +| `memory_save` | 保存洞察、决策或模式 | +| `memory_patterns` | 检测反复出现的模式 | +| `memory_smart_search` | 混合语义 + 关键词搜索 | +| `memory_file_history` | 关于特定文件的过去观测 | +| `memory_sessions` | 列出最近的会话 | +| `memory_timeline` | 按时间排列的观测 | +| `memory_profile` | 项目档案(概念、文件、模式) | +| `memory_export` | 导出所有记忆数据 | +| `memory_relations` | 查询关系图 | + +
+ +
+扩展工具(总 51 — 设置 AGENTMEMORY_TOOLS=all) + +| 工具 | 描述 | +|------|-------------| +| `memory_patterns` | 检测反复出现的模式 | +| `memory_timeline` | 按时间排列的观测 | +| `memory_relations` | 查询关系图 | +| `memory_graph_query` | 知识图谱遍历 | +| `memory_consolidate` | 运行 4 层整合 | +| `memory_claude_bridge_sync` | 与 MEMORY.md 同步 | +| `memory_team_share` | 与团队成员共享 | +| `memory_team_feed` | 最近共享条目 | +| `memory_audit` | 操作审计轨迹 | +| `memory_governance_delete` | 带审计轨迹的删除 | +| `memory_snapshot_create` | Git 版本快照 | +| `memory_action_create` | 创建带依赖的工作项 | +| `memory_action_update` | 更新动作状态 | +| `memory_frontier` | 按优先级排序的未阻塞动作 | +| `memory_next` | 单个最重要的下一动作 | +| `memory_lease` | 独占动作租约(多代理) | +| `memory_routine_run` | 实例化工作流例程 | +| `memory_signal_send` | 代理间消息 | +| `memory_signal_read` | 带回执读取消息 | +| `memory_checkpoint` | 外部条件门 | +| `memory_mesh_sync` | 实例间 P2P 同步 | +| `memory_sentinel_create` | 事件驱动监视器 | +| `memory_sentinel_trigger` | 外部触发哨兵 | +| `memory_sketch_create` | 临时动作图 | +| `memory_sketch_promote` | 提升为永久 | +| `memory_crystallize` | 紧凑化动作链 | +| `memory_diagnose` | 健康检查 | +| `memory_heal` | 自动修复卡住的状态 | +| `memory_facet_tag` | 维度:值 标签 | +| `memory_facet_query` | 按 facet 标签查询 | +| `memory_verify` | 追溯来源 | + +
+ +### 6 个资源 · 3 个提示词 · 4 个 Skills + +| 类型 | 名称 | 描述 | +|------|------|-------------| +| Resource | `agentmemory://status` | 健康、会话数、记忆数 | +| Resource | `agentmemory://project/{name}/profile` | 项目级智能 | +| Resource | `agentmemory://memories/latest` | 最新 10 条活跃记忆 | +| Resource | `agentmemory://graph/stats` | 知识图谱统计 | +| Prompt | `recall_context` | 搜索并返回上下文消息 | +| Prompt | `session_handoff` | 代理之间的交接数据 | +| Prompt | `detect_patterns` | 分析反复出现的模式 | +| Skill | `/recall` | 搜索记忆 | +| Skill | `/remember` | 保存到长期记忆 | +| Skill | `/session-history` | 最近的会话摘要 | +| Skill | `/forget` | 删除观测/会话 | + +### 独立 MCP + +无需完整服务器即可运行 — 适用于任何 MCP 客户端。以下两种都可以: + +```bash +npx -y @agentmemory/agentmemory mcp # 规范命令(始终可用) +npx -y @agentmemory/mcp # shim 包别名 +``` + +或添加到你的代理的 MCP 配置: + +大多数代理(Cursor、Claude Desktop、Cline、Roo Code、Windsurf、Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +把 `agentmemory` 条目合并到你的宿主现有的 `mcpServers` 对象中,而非替换文件。对于无法访问宿主 `localhost` 的沙盒客户端,在 env 块中添加 `"AGENTMEMORY_FORCE_PROXY": "1"`,并将 `AGENTMEMORY_URL` 设为沙盒能到达的路由。 + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +从仓库复制插件文件: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Real-Time Viewer

+ +在端口 `3113` 自动启动。实时观测流、会话浏览器、记忆浏览器、知识图谱可视化和健康仪表板。 + +```bash +open http://localhost:3113 +``` + +查看器服务器默认绑定 `127.0.0.1`。REST 提供的 `/agentmemory/viewer` 端点遵循正常的 `AGENTMEMORY_SECRET` bearer-token 规则。CSP 头使用每响应 script nonce 并禁用内联处理器属性(`script-src-attr 'none'`)。 + +--- + +

iii Console

+ +`:3113` 上的查看器展示你的代理**记住了什么**。[iii 控制台](https://iii.dev/docs/console) 展示你的代理**做了什么** — 每个记忆操作都是 OpenTelemetry trace,每个 KV 条目都可编辑,每个函数都可调用,每个流都可挂载。同一记忆的两个窗口:一个面向产品,一个面向引擎。 + +观察一次 `memory_smart_search` 触发,在瀑布图中看到 BM25 扫描 → 嵌入查找 → RRF 融合 → 重排器。在 KV 浏览器中编辑卡住的整合计时器。用调整后的负载重放一个 `PostToolUse` hook。固定 WebSocket 流,实时观察观测落地。 + +agentmemory 免费提供这一切,因为每个函数、触发器、状态作用域、流都是 iii 原语 — 没有定制,没有需要插桩的地方。 + +

+ iii console Workers page — connected workers including agentmemory instances with live function counts and runtime metadata +
+ Workers 页面:每个已连接的 worker — 包括 agentmemory 本身 — 显示 PID、函数数、运行时和最后在线时间。 +

+ +**已经装好了。** 控制台随 `iii` 一同发布 — 无需单独安装器。 + +**与 agentmemory 并行启动:** + +```bash +# agentmemory 查看器占用端口 3113,所以在 3114 运行控制台。 +# 引擎 REST (3111)、WebSocket (3112)、bridge (49134) 默认值与 agentmemory 匹配。 +iii console --port 3114 +``` + +然后打开 `http://localhost:3114`。加 `--enable-flow` 开启实验性架构图页面。 + +仅在你移动了引擎端点时才覆盖: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**控制台能做什么:** + +| 页面 | 用途 | +|------|-----------| +| **Workers** | 查看每个已连接 worker 及其实时指标 — 包括 agentmemory worker 本身。 | +| **Functions** | 直接用 JSON 负载调用 agentmemory 的任何函数 — 测试 `memory.recall`、`memory.consolidate`、`graph.query` 无需接入客户端。 | +| **Triggers** | 重放 HTTP、cron、事件和状态触发器 — 手动触发整合 cron、重试 HTTP 路由、发出状态变化。 | +| **States** | 完整 CRUD 的 KV 浏览器 — 会话、记忆槽位、生命周期计时器、嵌入索引 — 就地编辑值。 | +| **Streams** | 记忆写入、hook 事件和观测更新流经 iii 流时的实时 WebSocket 监视器。 | +| **Queues** | 持久队列主题 + 死信管理。重放或丢弃失败的嵌入/压缩任务。 | +| **Traces** | OpenTelemetry 瀑布/火焰/服务分解视图。按 `trace_id` 过滤,精确查看单次 `memory.search` 产生了哪些函数、DB 调用和嵌入请求。 | +| **Logs** | 结构化 OTEL 日志,过滤并与 trace/span ID 关联。 | +| **Config** | 运行时配置 — 看到引擎正在使用的 workers、提供者和端口。 | +| **Flow** | (可选,`--enable-flow`) 每个 worker、触发器和流的交互式架构图。 | + +

+ iii console trace waterfall view showing per-span duration +
+ Traces:每个记忆操作的瀑布/火焰/服务分解。 +

+ +**Traces 已开启:** + +`iii-config.yaml` 出厂启用 `iii-observability` worker(`exporter: memory`、`sampling_ratio: 1.0`、指标 + 日志)。无需额外配置 — agentmemory 启动那一刻,每个记忆操作都会发出一个 trace span 和一个控制台可读的结构化日志。 + +如果你想改为导出到 Jaeger/Honeycomb/Grafana Tempo,把 `exporter: memory` 改为 `exporter: otlp` 并按 iii 的可观测性文档设置收集器端点。 + +> **提醒:** 控制台本身未强制鉴权 — 保持其绑定 `127.0.0.1`(默认)并永远不要对外暴露。 + +--- + +

Powered by iii

+ +agentmemory **本身就是一个运行中的 [iii](https://iii.dev) 实例**。函数、触发器、KV 状态、流、OTEL traces — 全部都是 iii 原语。你没有安装 Postgres、Redis、Express、pm2 或 Prometheus,因为 iii 替代了它们。 + +这意味着多一条命令就能为 agentmemory 增加一整套新能力。 + +### 一条命令扩展 agentmemory + +```bash +iii worker add iii-pubsub # 把记忆写入扇出到每个连接的实例 +iii worker add iii-cron # 定时整合、衰减扫描、快照轮换 +iii worker add iii-queue # 嵌入 + 压缩任务的持久重试 +iii worker add iii-observability # 每个记忆操作的 OTEL traces(默认开启) +iii worker add iii-sandbox # 在隔离 microVM 内运行召回到的代码 +iii worker add iii-database # 切换 SQL 后端的状态适配器 +iii worker add mcp # 在 agentmemory 的 MCP 旁开通用 MCP 宿主 +``` + +每个 `iii worker add` 都会把新的函数和触发器注册到 agentmemory 正在运行的同一引擎中。查看器和控制台立即接收 — 无需重载、无需新集成、无需新容器。 + +| `iii worker add` | 在 agentmemory 上获得的额外能力 | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | 多实例记忆:每次 `remember` 扇出,每次 `search` 读取并集 | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | 定时生命周期 — 夜间整合、周快照、按固定时钟衰减 | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | 持久重试:失败的嵌入 + 压缩任务在重启后存活,无观测丢失 | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | 每个函数的 OTEL traces、指标、日志 — 从第一天起就接入 `iii-config.yaml` | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | `memory_recall` 出来的代码在一次性 VM 中运行,不在你的 shell 中 | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | 当默认的内存 KV 不够用时,SQL 后端状态适配器 | +| [`mcp`](https://workers.iii.dev/workers/mcp) | 在 agentmemory 的旁边架设额外 MCP 服务器,共享同一引擎 | + +完整注册表:[workers.iii.dev](https://workers.iii.dev)。那里的每个 worker 都通过 agentmemory 所用的同样原语组合 — 而你已经拥有的 agentmemory 本身就是其中之一。 + +### iii 替代了什么 + +| 传统栈 | agentmemory 使用 | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + 内存向量索引 | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | iii engine worker 监管 | +| Prometheus / Grafana | iii OTEL + 健康监控 | +| 自定义插件系统 | `iii worker add ` | + +**118 个源文件 · ~21,800 行代码 · 950+ 测试 · 123 个函数 · 34 个 KV 作用域** — 全部基于三种原语。没有 `agentmemory plugin install`。插件系统就是 iii 本身。 + +--- + +

Configuration

+ +### LLM 提供者 + +agentmemory 从你的环境自动检测。默认情况下,除非你配置提供者或显式启用 Claude 订阅回退,否则不会发起 LLM 调用。 + +| 提供者 | 配置 | 备注 | +|----------|--------|-------| +| **No-op(默认)** | 无需配置 | LLM 驱动的 compress/summarize 被禁用。合成 BM25 压缩 + 召回仍可用。如果你以前依赖 Claude 订阅回退,请见下面的 `AGENTMEMORY_ALLOW_AGENT_SDK`。 | +| Anthropic API | `ANTHROPIC_API_KEY` | 按 token 计费 | +| MiniMax | `MINIMAX_API_KEY` | Anthropic 兼容 | +| Gemini | `GEMINI_API_KEY` | 同时启用嵌入 | +| OpenRouter | `OPENROUTER_API_KEY` | 任意模型 | +| Claude 订阅回退 | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | 仅按需启用。会派生 `@anthropic-ai/claude-agent-sdk` 会话 — 曾导致无限 Stop-hook 递归故不再默认。 | + +### 成本感知的模型选择 + +后台压缩在每次观测时运行,模型选择会显著影响月度支出。捕获的工作负载数据:635 次请求 / 888K tokens / 35 小时活跃使用,基于 2026-05-23 OpenRouter 定价对三个模型评测。 + +| 等级 | 模型 | 输入 / 1M | 输出 / 1M | 35 小时捕获工作负载成本 | 备注 | +|------|-------|------------|-------------|---------------------------|-------| +| 推荐 | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | 压缩 + 摘要质量稳定,比 Sonnet 便宜 ~10×。 | +| 推荐 | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | 略旧但仍胜任仅压缩工作负载。 | +| 推荐 | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | 如果你的会话多为代码,代码推理能力强。 | +| 高级 | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | 质量高但对长期后台工作来说成本昂贵。 | +| 高级 | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | 与 Sonnet 同档。 | +| 避免 | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | 推理级模型;用于压缩属于巨额超支。 | + +当 `OPENROUTER_MODEL` 匹配高级层模式时,agentmemory 会打印运行时警告。在做出知情选择后,设置 `AGENTMEMORY_SUPPRESS_COST_WARNING=1` 来消音。 + +记忆工作的质量-成本权衡:压缩是质量门槛相对宽松的摘要任务(代理重新阅读摘要,而非用户)。DeepSeek-V4-Pro / Qwen3-Coder 在该任务上与 Sonnet 误差极小,而成本约低 10×。把高级层模型留给你直接阅读的查询。 + +来源:[OpenRouter Sonnet 4.6 定价](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing)、[DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro)、[DeepSeek 定价说明](https://api-docs.deepseek.com/quick_start/pricing/)。 + +### 多代理记忆(`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +在多个角色共享一台 agentmemory 服务器的多代理设置中(architect / developer / reviewer / researcher / support-agent),`AGENT_ID` 给每次写入打上发起角色的标签。`AGENTMEMORY_AGENT_SCOPE` 控制召回是否按该标签过滤。 + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # 可选;默认 "shared" +``` + +两种模式: + +| 模式 | 标记写入 | 过滤召回 | 何时使用 | +|------|------------|---------------|-------------| +| `shared`(默认) | 是 | 否 | 跨代理共享上下文且带审计轨迹。Architect 能看到 developer 记下了什么,但每条记录都标明发言者。 | +| `isolated` | 是 | 是 | 严格隔离。Architect 永远不会看到 developer 的观测/记忆/会话。 | + +设置 `AGENT_ID` 后会被标记的内容:`Session.agentId`、`RawObservation.agentId`、`CompressedObservation.agentId`、`Memory.agentId`。角色从 `api::session::start` → `mem::observe` → `mem::compress` → KV 流转。 + +isolated 模式下被过滤的内容:`mem::smart-search`、`/agentmemory/memories`、`/agentmemory/observations`、`/agentmemory/sessions`。每个端点都接受 `?agentId=` 来按请求覆盖,以及 `?agentId=*` 来完全跳过环境作用域。`/memories` 还接受 `?includeOrphans=true` 来浮现 `agentId` 为 undefined 的预-AGENT_ID 记忆。 + +SDK / REST 层的按调用覆盖:每个修改端点(`/session/start`、`/remember`)都接受请求体中的 `agentId` 字段,胜过环境变量。对于在一个服务器进程中路由多角色的运行时很有用。 + +当 `AGENT_ID` 未设置时,记忆保持无作用域(遗留行为,无标签、无过滤)。 + +### 端口 + +agentmemory + iii-engine 默认绑定四个端口。如果重启失败并显示 `port in use`,这张表告诉你该查找什么进程。 + +| 端口 | 进程 | 用途 | 环境覆盖 | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | 内部流 worker(由 agentmemory + 查看器消费) | `III_STREAMS_PORT` | +| `3113` | agentmemory | 实时查看器(`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — workers 在此注册,OTel 遥测在此流过 | `III_ENGINE_URL`(完整 URL,默认 `ws://localhost:49134`) | + +崩溃后端口仍被占用时的陈旧进程清理: + +```bash +# macOS / Linux — 查找每个端口上的进程并杀掉 +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` 在优雅关闭时干净地回收 worker 和 engine pidfile。上面的手动清理仅针对崩溃后两个 pidfile 都未留下的情况。 + +### 配置文件 + +把 agentmemory 运行时配置放到 `~/.agentmemory/.env`,而不是在每个 shell 中 export 变量。如果查看器显示像 `export ANTHROPIC_API_KEY=...` 这样的设置提示,把它复制到该文件中作为 `ANTHROPIC_API_KEY=...`(去掉 `export` 前缀),然后重启 agentmemory。 + +进程环境变量仍然有效,优先级高于文件中的值。 + +在 Windows 上,同一文件位于 `%USERPROFILE%\.agentmemory\.env`: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +要用 Claude Code Pro/Max 订阅而非 API key 测试,显式启用: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +如果想开启图或整合特性,在同一文件中打开: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### 环境变量 + +创建 `~/.agentmemory/.env`: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +端口 `3111` 上的 124 个端点。REST API 默认绑定 `127.0.0.1`。当 `AGENTMEMORY_SECRET` 已设置时,受保护端点需要 `Authorization: Bearer `,网状同步端点要求两端都设置 `AGENTMEMORY_SECRET`。 + +
+关键端点 + +| 方法 | 路径 | 描述 | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | 健康检查(始终公开) | +| `POST` | `/agentmemory/session/start` | 开始会话 + 获取上下文 | +| `POST` | `/agentmemory/session/end` | 结束会话 | +| `POST` | `/agentmemory/observe` | 捕获观测 | +| `POST` | `/agentmemory/smart-search` | 混合搜索 | +| `POST` | `/agentmemory/context` | 生成上下文 | +| `POST` | `/agentmemory/remember` | 保存到长期记忆 | +| `POST` | `/agentmemory/forget` | 删除观测 | +| `POST` | `/agentmemory/enrich` | 文件上下文 + 记忆 + bugs | +| `GET` | `/agentmemory/profile` | 项目档案 | +| `GET` | `/agentmemory/export` | 导出所有数据 | +| `POST` | `/agentmemory/import` | 从 JSON 导入 | +| `POST` | `/agentmemory/graph/query` | 知识图谱查询 | +| `POST` | `/agentmemory/team/share` | 与团队共享 | +| `GET` | `/agentmemory/audit` | 审计轨迹 | + +完整端点列表:[`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Development

+ +```bash +npm run dev # 热重载 +npm run build # 生产构建 +npm test # 950+ 测试 +npm run test:integration # API 测试(需要服务运行中) +``` + +**先决条件:** Node.js >= 20、[iii-engine](https://iii.dev/docs) 或 Docker + +

License

+ +[Apache-2.0](../LICENSE) diff --git a/READMEs/README.zh-TW.md b/READMEs/README.zh-TW.md new file mode 100644 index 0000000..75b2fc9 --- /dev/null +++ b/READMEs/README.zh-TW.md @@ -0,0 +1,1377 @@ +

+ agentmemory — 為 AI 編碼代理提供持久化記憶 +

+ +

+ + 讓你的編碼代理記住一切。不再重複解釋。 + Built on iii engine +
+ 為 Claude Code、Cursor、Gemini CLI、Codex CLI、Hermes、OpenClaw、pi、OpenCode 以及任何 MCP 用戶端提供持久化記憶。 +

+ +

+ English | + 简体中文 | + 繁體中文 | + 日本語 | + 한국어 | + Español | + Türkçe | + Русский | + हिन्दी | + Português | + Français | + Deutsch +

+ +

+ rohitg00/agentmemory | Trendshift +

+ +

+ + + + + Star History Chart + + +

+ +

+ Design doc: 1200 stars / 172 forks on the gist +

+ +

+ 這份 gist 以信心評分、生命週期管理、知識圖譜和混合搜尋擴展了 Karpathy 的 LLM Wiki 模式:agentmemory 就是其實作。 +

+ +

+ npm version + CI + License + Stars +

+ +

+ 95.2% retrieval R@5 + 92% fewer tokens + 53 MCP tools + 12 auto hooks + 0 external DBs + 950+ tests passing +

+ +

+ agentmemory demo +

+ +

+ 安裝 • + 快速開始 • + 基準測試 • + 對比競品 • + 代理 • + 運作原理 • + MCP • + 檢視器 • + iii 主控台 • + 由 iii 驅動 • + 設定 • + API +

+ +--- + +## 安裝 + +```bash +npm install -g @agentmemory/agentmemory # 一次安裝 — 全域可用 `agentmemory` 指令 +# 如果在 macOS/Linux 的系統 Node 上遇到 EACCES,請重試: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # 在 :3111 啟動記憶伺服器 +agentmemory demo # 注入範例會話並驗證召回 +agentmemory connect claude-code # 連接你的代理(也支援: codex, cursor, gemini-cli, ...) +``` + +或透過 `npx`(無需安裝): + +```bash +npx @agentmemory/agentmemory +``` + +提醒 — npx 會依版本快取。若裸 `npx @agentmemory/agentmemory` 指令執行的是舊版,強制使用最新版 `npx -y @agentmemory/agentmemory@latest`,或一次性清除快取 `rm -rf ~/.npm/_npx`(macOS/Linux;Windows 上刪除 `%LOCALAPPDATA%\npm-cache\_npx`)。從 v0.9.16+ 起,首次 npx 執行會以行內方式提示你全域安裝,之後裸 `agentmemory` 指令在任何地方都能用。 + +完整選項見下方[快速開始](#quick-start)。各代理具體接入見[支援所有代理](#works-with-every-agent)。 + +--- + +

Works with every agent

+ +agentmemory 相容任何支援 hooks、MCP 或 REST API 的代理。所有代理共享同一個記憶伺服器。 + + + + + + + + + + + + + + + + + + + + + + +
+Claude Code
+Claude Code
+原生外掛 + 12 hooks + MCP +
+Codex CLI
+Codex CLI
+原生外掛 + 6 hooks + MCP +
+OpenClaw
+OpenClaw
+原生外掛 + MCP +
+Hermes
+Hermes
+原生外掛 + MCP +
+pi
+pi
+原生外掛 + MCP +
+OpenHuman
+OpenHuman
+原生 Memory trait 後端 +
+Cursor
+Cursor
+MCP 伺服器 +
+Gemini CLI
+Gemini CLI
+MCP 伺服器 +
+OpenCode
+OpenCode
+22 hooks + MCP + 外掛 +
+Cline
+Cline
+MCP 伺服器 +
+Goose
+Goose
+MCP 伺服器 +
+Kilo Code
+Kilo Code
+MCP 伺服器 +
+Aider
+Aider
+REST API +
+Claude Desktop
+Claude Desktop
+MCP 伺服器 +
+Windsurf
+Windsurf
+MCP 伺服器 +
+Roo Code
+Roo Code
+MCP 伺服器 +
+ +

+ 相容任何使用 MCP 或 HTTP 的代理。一個伺服器,所有代理共享記憶。 +

+ +--- + +你每次會話都在重複解釋同樣的架構。你反覆發現同樣的 bug。你重複教同樣的偏好。內建的記憶(CLAUDE.md、.cursorrules)上限是 200 行而且會過期。agentmemory 解決了這個問題。它在背景靜默捕捉代理的行為,將其壓縮為可搜尋的記憶,並在下次會話開始時注入正確的上下文。一條指令。跨代理工作。 + +**改變了什麼:** 會話 1 你設定了 JWT 驗證。會話 2 你要求限流。代理已經知道你的驗證使用 `src/middleware/auth.ts` 中的 jose middleware,測試覆蓋了 token 驗證,你選擇 jose 而非 jsonwebtoken 是為了 Edge 相容性。無需重新解釋。無需複製貼上。代理就是*知道*。 + +```bash +npx @agentmemory/agentmemory +``` + +> **v0.9.0 新功能** — 著陸頁 [agent-memory.dev](https://agent-memory.dev) 上線,檔案系統連接器(`@agentmemory/fs-watcher`),獨立 MCP 現在代理至執行中的伺服器,使 hooks 和檢視器保持一致,稽核策略在所有刪除路徑上得到統一,健康狀態在小型 Node 行程上不再誤報 `memory_critical`。完整變更見 [CHANGELOG.md](../CHANGELOG.md#090--2026-04-18)。 + +--- + +

Benchmarks

+ + + + + + +
+ +### 檢索準確率 + +**coding-agent-life-v1** (內部語料庫,沙盒可重現) + +| 適配器 | P@5 | R@5 | Top-5 命中率 | p50 延遲 | +|---|---|---|---|---| +| **agentmemory 混合** | **0.578** | **0.967** | **15 / 15** | 14 ms | +| grep 基線 | 0.267 | 0.967 | 15 / 15 | 0 ms | + +100% Top-5 命中率。在相同輸入下,精確度比 grep 基線高 **2.2×**。完整依類型分解:[`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 個問題) + +| 系統 | R@5 | R@10 | MRR | +|---|---|---|---| +| **agentmemory** | **95.2%** | **98.6%** | **88.2%** | +| 僅 BM25 回退 | 86.2% | 94.6% | 71.5% | + + + +### Token 節省 + +| 方法 | Token/年 | 成本/年 | +|---|---|---| +| 貼上完整上下文 | 19.5M+ | 不可能(超出窗口) | +| LLM 摘要 | ~650K | ~$500 | +| **agentmemory** | **~170K** | **~$10** | +| agentmemory + 本地嵌入 | ~170K | **$0** | + +
+ +> 嵌入模型:`all-MiniLM-L6-v2`(本地、免費、無需 API key)。完整報告:[`benchmark/LONGMEMEVAL.md`](../benchmark/LONGMEMEVAL.md)、[`benchmark/QUALITY.md`](../benchmark/QUALITY.md)、[`benchmark/SCALE.md`](../benchmark/SCALE.md)。競品比較:[`benchmark/COMPARISON.md`](../benchmark/COMPARISON.md) — agentmemory 比較 mem0、Letta、Khoj、claude-mem、Hippo。 + +**在地重現:** [`eval/README.md`](../eval/README.md) — 適配器可插拔的 harness,支援 LongMemEval `_s`(公開 500 問)+ `coding-agent-life-v1`(內部 15 會話語料)。Grep / 向量 / agentmemory 適配器並排計分,NDJSON 輸出,公開計分卡發布於 [`docs/benchmarks/`](../docs/benchmarks/)。 + +**搭配 [codegraph](https://github.com/colbymchenry/codegraph)、[Understand Anything](https://github.com/Lum1104/Understand-Anything) 和 [Graphify](https://github.com/safishamsi/graphify) 使用。** 程式碼圖索引、多代理建置流水線,以及跨文件 / PDF / 圖片 / 影片的更廣泛知識圖譜。agentmemory 記住工作內容;這三個專案點亮上下文層其餘部分。組合配方與問題路由表:[`docs/recipes/pairings.md`](../docs/recipes/pairings.md)。 + +--- + +

vs Competitors

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
agentmemorymem0 (53K ⭐)Letta / MemGPT (22K ⭐)內建 (CLAUDE.md)
類型記憶引擎 + MCP 伺服器記憶層 API完整代理執行階段靜態檔案
檢索 R@595.2%68.5% (LoCoMo)83.2% (LoCoMo)N/A (grep)
自動捕捉12 hooks(零人工)手動呼叫 add()代理自編輯手動編輯
搜尋BM25 + 向量 + 圖(RRF 融合)向量 + 圖向量(歸檔)把所有內容載入上下文
多代理MCP + REST + 租約 + 訊號API(無協調)僅在 Letta 執行階段內部每個代理一個檔案
框架鎖定無(任何 MCP 用戶端)高(必須使用 Letta)每個代理格式
外部相依無(SQLite + iii-engine)Qdrant / pgvectorPostgres + 向量資料庫
記憶生命週期4 層整合 + 衰減 + 自動遺忘被動擷取代理管理手動清理
Token 效率~1,900 tokens/會話 ($10/年)依整合方式不同核心記憶位於上下文240 條觀測達 22K+ tokens
即時檢視器是(連接埠 3113)雲端儀表板雲端儀表板
自架是(預設)選用選用
+ +--- + +

Quick Start

+ +相容性:此版本面向穩定的 `iii-sdk` `^0.11.0` 和 iii-engine v0.11.x。 + +### 30 秒體驗 + +```bash +# 終端 1:啟動伺服器 +npx @agentmemory/agentmemory + +# 終端 2:注入範例資料並查看召回 +npx @agentmemory/agentmemory demo +``` + +`demo` 會注入 3 個真實會話(JWT 驗證、N+1 查詢修正、限流)並對它們執行語義搜尋。你將看到搜尋「資料庫效能最佳化」時找到「N+1 查詢修正」 — 關鍵字比對做不到這一點。 + +打開 `http://localhost:3113` 即時觀察記憶的建構過程。 + +### 推薦:全域安裝 + +`npx` 依版本快取。若你上週執行過 `npx @agentmemory/agentmemory@0.9.14`,裸 `npx @agentmemory/agentmemory` 指令可能會從 `~/.npm/_npx/` 提供過期的 0.9.14 而非最新版。安裝一次後,裸 `agentmemory` 指令處處可用: + +```bash +npm install -g @agentmemory/agentmemory +# 如果在 macOS/Linux 的系統 Node 上遇到 EACCES,請重試: +# sudo npm install -g @agentmemory/agentmemory +agentmemory # 啟動伺服器(等同 npx 形式) +agentmemory stop # 停止 +agentmemory remove # 解除安裝所有建立的內容 +agentmemory connect claude-code # 連接一個代理 +agentmemory doctor # 互動式診斷 + 修復提示 +``` + +從 v0.9.16 開始,首次 npx 執行會以行內方式提示你全域安裝 — 回答一次 `Y` 即可。若你跳過,可使用以下任一方式取得最新版本: + +```bash +npx -y @agentmemory/agentmemory@latest # 強制從 npm 拉取最新(跨平台) +rm -rf ~/.npm/_npx && npx @agentmemory/agentmemory # 僅 macOS/Linux (POSIX shell) +``` + +在 Windows / PowerShell 上,等價的快取清除指令是 `Remove-Item -Recurse -Force "$env:LOCALAPPDATA\npm-cache\_npx"` — 上面的 `npx -y ...@latest` 形式是跨平台選項。 + +### 會話重播 + +agentmemory 紀錄的每個會話都可重播。打開檢視器,選擇 **Replay** 標籤,在時間軸上拖動:提示、工具呼叫、工具結果和回應都以離散事件呈現,支援播放/暫停、速度控制(0.5×–4×)和鍵盤快捷鍵(空白鍵切換,方向鍵單步)。 + +已有舊的 Claude Code JSONL 紀錄想匯入? + +```bash +# 匯入預設 ~/.claude/projects 下的全部內容 +npx @agentmemory/agentmemory import-jsonl + +# 或匯入單一檔案 +npx @agentmemory/agentmemory import-jsonl ~/.claude/projects/-my-project/abc123.jsonl +``` + +匯入的會話與原生會話一同出現在 Replay 選擇器中。底層每個條目都透過 `mem::replay::load`、`mem::replay::sessions`、`mem::replay::import-jsonl` 這些 iii 函式路由 — 沒有側通道伺服器。 + +### 升級 / 維護 + +當你確實想更新本地執行階段時,使用維護指令: + +```bash +npx @agentmemory/agentmemory upgrade +``` + +警告:此指令會變更目前工作區/執行階段。它可能更新 JavaScript 相依,並拉取固定版本的 Docker 鏡像 `iiidev/iii:0.11.2`。它絕不會安裝未固定版本或更新的 iii 引擎。 + +實作細節見 `src/cli.ts`(參考 `src/cli.ts:544-595` 附近的 `runUpgrade`)。 + +### Claude Code(一段話,直接貼上) + +```text +Install agentmemory: run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server. Then run `/plugin marketplace add rohitg00/agentmemory` and `/plugin install agentmemory` — the plugin registers all 12 hooks, 4 skills, AND auto-wires the `@agentmemory/mcp` stdio server via its `.mcp.json`, so you get 53 MCP tools (memory_smart_search, memory_save, memory_sessions, memory_governance_delete, etc.) without any extra config step. Verify with `curl http://localhost:3111/agentmemory/health`. The real-time viewer is at http://localhost:3113. +``` + +#### Claude Code 不安裝外掛(MCP-standalone 路徑) + +若你直接透過 `~/.claude.json` 連接 agentmemory 的 MCP 伺服器而非使用 `/plugin install`,Claude Code 永遠不會解析 `${CLAUDE_PLUGIN_ROOT}`,你必須把 hook 腳本指向 `~/.claude/settings.json` 中的絕對路徑。這些路徑通常會嵌入 agentmemory 版本號(例如 `~/.codex/plugins/cache/agentmemory/agentmemory/0.9.21/scripts/…`),因此下次升級會靜默破壞所有 hooks。 + +變通方法: + +```bash +agentmemory connect claude-code --with-hooks +``` + +這會把同樣的 hook 指令合併到 `~/.claude/settings.json`,絕對路徑解析到目前安裝的 `@agentmemory/agentmemory` 套件的 `plugin/` 目錄。升級 agentmemory 後重新執行該指令以重新整理路徑。同一檔案中的使用者條目會被保留;只取代之前的 agentmemory 條目。仍然推薦使用 `/plugin install` 路徑。 + +對於遠端或受保護的部署,啟動 Claude Code 時設定 `AGENTMEMORY_URL` 和 `AGENTMEMORY_SECRET`。外掛會把這兩個值傳遞給其捆綁的 MCP 伺服器;當 `AGENTMEMORY_URL` 為空時,MCP shim 預設使用 `http://localhost:3111`。 + +### Codex CLI(Codex 外掛平台) + +```bash +# 1. 在另一個終端啟動記憶伺服器 +npx @agentmemory/agentmemory + +# 2. 註冊 agentmemory 市集並安裝外掛 +codex plugin marketplace add rohitg00/agentmemory +codex plugin add agentmemory@agentmemory +``` + +Codex 外掛與 Claude Code 外掛同源,來自相同的 `plugin/` 目錄。它註冊: + +- `@agentmemory/mcp` 作為 MCP 伺服器(當 `AGENTMEMORY_URL` 指向執行中的 agentmemory 伺服器時,代理全部 51 個工具;若伺服器不可達,本地回退至 7 個工具) +- 6 個生命週期 hooks:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`PreCompact`、`Stop` +- 4 個 skills:`/recall`、`/remember`、`/session-history`、`/forget` + +Codex 的 hook 引擎會把 `CLAUDE_PLUGIN_ROOT` 注入 hook 子行程(參見 [`codex-rs/hooks/src/engine/discovery.rs`](https://github.com/openai/codex/blob/main/codex-rs/hooks/src/engine/discovery.rs)),因此同樣的 hook 腳本在兩個宿主中都能運作,無需重複實作。Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure 事件僅 Claude Code 支援,Codex 未註冊這些。 + +#### Codex Desktop:外掛 hooks 目前沒有回應(有變通方法) + +`CodexHooks` 和 `PluginHooks` 在 [`codex-rs/features/src/lib.rs`](https://github.com/openai/codex/blob/main/codex-rs/features/src/lib.rs) 中都已穩定且預設啟用,但 Codex Desktop 目前不會派發外掛本地的 `hooks.json`([openai/codex#16430](https://github.com/openai/codex/issues/16430))。MCP 工具仍能運作;只是生命週期觀測缺失。 + +在上游修正落地前,把同樣的 hook 指令鏡像到全域 `~/.codex/hooks.json`: + +```bash +agentmemory connect codex --with-hooks +``` + +這會在 `~/.codex/hooks.json` 新增一個冪等區塊,引用捆綁腳本的絕對路徑(在使用者範圍下無需 `${CLAUDE_PLUGIN_ROOT}` 展開)。升級 agentmemory 後重新執行同一指令以重新整理路徑。同一檔案中的使用者條目會被保留;只取代之前的 agentmemory 條目。 + +
+OpenClaw(貼上此提示) + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to my OpenClaw MCP config so agentmemory is available with all 51 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper memory-slot integration, copy `integrations/openclaw` to `~/.openclaw/extensions/agentmemory` and enable `plugins.slots.memory = "agentmemory"` in `~/.openclaw/openclaw.json`. +``` + +完整指南:[`integrations/openclaw/`](../integrations/openclaw/) + +
+ +
+Hermes Agent(貼上此提示) + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. Then add this to ~/.hermes/config.yaml so Hermes can use agentmemory as an MCP server with all 51 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. For deeper 6-hook memory provider integration (pre-LLM context injection, turn capture, MEMORY.md mirroring, system prompt block), copy integrations/hermes from the agentmemory repo to ~/.hermes/plugins/agentmemory. +``` + +完整指南:[`integrations/hermes/`](../integrations/hermes/) + +
+ +### 其他代理 + +啟動記憶伺服器:`npx @agentmemory/agentmemory` + +在使用 `mcpServers` 結構的每個宿主(Cursor、Claude Desktop、Cline、Roo Code、Windsurf、Gemini CLI、OpenClaw)中,agentmemory 條目是**相同的 MCP 伺服器區塊**: + +```json +"agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET}" + } +} +``` + +**把此條目合併到宿主設定檔現有的 `mcpServers` 物件中** — 不要取代整個檔案。若檔案已有其他伺服器,把 `agentmemory` 作為另一個 key 加在它們旁邊。若完全缺少 `mcpServers`,把整個區塊貼到 `{ "mcpServers": { ... } }` 裡。`${VAR}` 佔位符會在 MCP 伺服器啟動時從 shell 繼承 `AGENTMEMORY_URL` / `AGENTMEMORY_SECRET` — 未設定的變數傳空字串,shim 回退到 `http://localhost:3111`。一個接好的條目同時涵蓋本地和遠端(k8s / 反向代理)部署。 + +| 代理 | 設定檔 | 備註 | +|---|---|---| +| **Cursor** | `~/.cursor/mcp.json` | 合併到 `mcpServers`。網站上也提供一鍵深層連結。 | +| **Claude Desktop** | `claude_desktop_config.json`(Application Support) | 合併到 `mcpServers`。編輯後重新啟動 Claude Desktop。 | +| **Cline / Roo Code / Kilo Code** | Cline MCP 設定(設定 UI → MCP Servers → Edit) | 同樣的 `mcpServers` 區塊。 | +| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | 同樣的 `mcpServers` 區塊。 | +| **Gemini CLI** | `~/.gemini/settings.json` | `gemini mcp add agentmemory npx -y @agentmemory/mcp --scope user`(自動合併)。 | +| **OpenClaw** | OpenClaw MCP 設定 | 同樣的 `mcpServers` 區塊,或使用更深的[記憶外掛](../integrations/openclaw/)。 | +| **Codex CLI(僅 MCP)** | `.codex/config.toml` | TOML 形式:`codex mcp add agentmemory -- npx -y @agentmemory/mcp`,或手動新增 `[mcp_servers.agentmemory]`。 | +| **Codex CLI(完整外掛)** | Codex 外掛市集 | `codex plugin marketplace add rohitg00/agentmemory` 然後 `codex plugin add agentmemory@agentmemory`。註冊 MCP + 6 個生命週期 hooks(SessionStart、UserPromptSubmit、PreToolUse、PostToolUse、PreCompact、Stop)+ 4 個 skills。在 Codex Desktop 上,直到 [openai/codex#16430](https://github.com/openai/codex/issues/16430) 落地之前,還要執行 `agentmemory connect codex --with-hooks` — 那裡的外掛 hooks 目前沒有回應。 | +| **OpenCode(僅 MCP)** | `opencode.json` | 不同結構 — 頂層 `mcp` key,command 是陣列:`{"mcp": {"agentmemory": {"type": "local", "command": ["npx", "-y", "@agentmemory/mcp"], "enabled": true}}}`。 | +| **OpenCode(完整外掛)** | `plugin/opencode/` | 22 個自動捕捉 hooks,涵蓋會話生命週期、訊息、工具、錯誤。兩個斜線指令(`/recall`、`/remember`)。把 `plugin/opencode/` 複製到你的 OpenCode 工作區並把外掛條目新增到 `opencode.json`。完整 hook 表與差異分析見 [`plugin/opencode/README.md`](../plugin/opencode/README.md)。 | +| **pi** | `~/.pi/agent/extensions/agentmemory` | 複製 [`integrations/pi`](../integrations/pi/) 並重啟 pi。 | +| **Hermes Agent** | `~/.hermes/config.yaml` | 使用更深的[記憶提供者外掛](../integrations/hermes/),設定 `memory.provider: agentmemory`。 | +| **Qwen Code** | `~/.qwen/settings.json` | `agentmemory connect qwen` 會寫入標準的 `mcpServers` 區塊。Hook 負載與 Claude Code 欄位相容,因此既有的 12 hook 腳本無需修改即可運作 — 透過同一 `settings.json` 的 `hooks` 區段連接它們。 | +| **Antigravity**(取代 Gemini CLI) | `mcp_config.json`(在 Antigravity 的 User 目錄中) | `agentmemory connect antigravity` 會寫入標準的 `mcpServers` 區塊。macOS: `~/Library/Application Support/Antigravity/User/`。Linux: `~/.config/Antigravity/User/`。在 2026-06-18 Gemini CLI 停止服務後使用。 | +| **Kiro** | `~/.kiro/settings/mcp.json` | `agentmemory connect kiro` 寫入使用者層級設定。工作區覆寫放在你的程式碼旁的 `.kiro/settings/mcp.json` 中。 | +| **Goose** | Goose MCP 設定 UI | 同樣的 `mcpServers` 區塊。 | +| **Aider** | n/a | 直接呼叫 REST API:`curl -X POST http://localhost:3111/agentmemory/smart-search -d '{"query": "auth"}'`。 | +| **任何代理(32+)** | n/a | `npx skillkit install agentmemory` 自動偵測宿主並合併。 | + +**沙箱化的 MCP 用戶端**(Flatpak / Snap / 受限容器)無法存取宿主的 `localhost`:還要在 `env` 區塊中設定 `"AGENTMEMORY_FORCE_PROXY": "1"`,並把 `AGENTMEMORY_URL` 指向沙箱確實能到達的路由(例如你的 LAN IP)。 + +### 程式化存取(Python / Rust / Node) + +agentmemory 把核心操作註冊為 iii 函式(`mem::remember`、`mem::observe`、`mem::context`、`mem::smart-search`、`mem::forget`)。任何擁有 iii SDK 的語言都可以透過 `ws://localhost:49134` 直接呼叫它們 — 無需為每種語言準備獨立的 REST 用戶端。 + +```bash +pip install iii-sdk # Python +cargo add iii-sdk # Rust +npm install iii-sdk # Node +``` + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh"}, +}) +``` + +完整範例:[`examples/python/`](../examples/python/)(快速開始 + 觀測/召回流程)。`:3111` 上的 REST 對沒有 iii 執行階段的宿主仍可用。 + +### 從原始碼建置 + +```bash +git clone https://github.com/rohitg00/agentmemory.git && cd agentmemory +npm install && npm run build && npm start +``` + +若 `iii` 已安裝,這會以本地 `iii-engine` 啟動 agentmemory;若 Docker 可用,則回退到 Docker Compose。REST、串流和檢視器預設繫結到 `127.0.0.1`。 + +手動安裝 `iii-engine`。**agentmemory 目前把 `iii-engine` 釘在 `v0.11.2`** — `v0.11.6` 引入了新的「透過 `iii worker add` 沙盒化一切」模型,agentmemory 尚未為此重構。重構落地後即解除釘版。若你已手動遷移到沙盒模型,可用 `AGENTMEMORY_III_VERSION=` 覆寫。 + +- **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:** 把 `aarch64-apple-darwin` 換成 `x86_64-apple-darwin` +- **Linux x64:** 換成 `x86_64-unknown-linux-gnu` +- **Linux arm64:** 換成 `aarch64-unknown-linux-gnu` +- **Windows:** 從 [iii-hq/iii releases v0.11.2](https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2) 下載 `iii-x86_64-pc-windows-msvc.zip`,擷取 `iii.exe`,加入 PATH + +或使用 Docker(捆綁的 `docker-compose.yml` 會拉取 `iiidev/iii:0.11.2`)。完整文件:[iii.dev/docs](https://iii.dev/docs)。 + +### Windows + +agentmemory 可在 Windows 10/11 執行,但僅 Node.js 套件不夠 — 你還需要 `iii-engine` 執行階段(一個獨立的原生二進位)作為背景行程。官方上游安裝器是 `sh` 指令稿,目前沒有 PowerShell 安裝器或 scoop/winget 套件,因此 Windows 使用者有兩條路徑: + +**選項 A — 預建 Windows 二進位(推薦):** + +```powershell +# 1. 在瀏覽器打開 https://github.com/iii-hq/iii/releases/tag/iii%2Fv0.11.2 +# (我們釘在 v0.11.2,直到 agentmemory 為 v0.11.6+ 引擎需求的 +# 新沙盒模型完成重構) +# 2. 下載 iii-x86_64-pc-windows-msvc.zip +# (若是 ARM 機器則下載 iii-aarch64-pc-windows-msvc.zip) +# 3. 把 iii.exe 解壓到 PATH 上的某處,或放在: +# %USERPROFILE%\.local\bin\iii.exe +# (agentmemory 會自動檢查該位置) +# 4. 驗證: +iii --version +# 應輸出:0.11.2 + +# 5. 然後照常執行 agentmemory: +npx -y @agentmemory/agentmemory +``` + +**選項 B — Docker Desktop:** + +```powershell +# 1. 安裝 Docker Desktop for Windows +# 2. 啟動 Docker Desktop 並確保引擎執行中 +# 3. 執行 agentmemory — 它會自動啟動捆綁的 compose 檔: +npx -y @agentmemory/agentmemory +``` + +**選項 C — 僅獨立 MCP(無引擎):** 若你只需要 MCP 工具供代理使用,不需要 REST API、檢視器或定時工作,則完全跳過引擎: + +```powershell +npx -y @agentmemory/agentmemory mcp +# 或透過 shim 套件: +npx -y @agentmemory/mcp +``` + +**Windows 診斷:** 若 `npx @agentmemory/agentmemory` 失敗,加 `--verbose` 重新執行以看到實際的引擎 stderr。常見失敗模式: + +| 症狀 | 修正 | +|---|---| +| `iii-engine process started` 然後 `did not become ready within 15s` | 引擎啟動當機 — 用 `--verbose` 重新執行,檢查 stderr | +| `Could not start iii-engine` | `iii.exe` 和 Docker 都未安裝。見上面選項 A 或 B | +| 連接埠衝突 | `netstat -ano \| findstr :3111` 查看佔用,然後 kill 或用 `--port ` | +| Docker 已安裝但仍跳過回退 | 確保 Docker Desktop 確實在執行(系統匣圖示) | + +> 注意:iii **引擎** 是預建的二進位檔,而非 cargo crate — 請勿嘗試以 `cargo install` 安裝它。(iii 的 **SDK** 確實已發布到 crates.io、npm 和 PyPI,但 agentmemory 並不需要它們。)受支援的引擎安裝方式皆固定為 v0.11.2:上述預建的 v0.11.2 二進位、**帶版本固定** 的上游 `sh` 安裝指令稿 `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=0.11.2 sh`(macOS/Linux),以及 Docker 鏡像 `iiidev/iii:0.11.2`。直接執行 `install.sh | sh` 會安裝 **最新** 引擎,而 agentmemory 並不支援該版本 — 請務必傳入 `VERSION=0.11.2`。最簡單的方式:直接執行 `npx @agentmemory/agentmemory`,它會為你把固定版本的引擎取得到 `~/.agentmemory/bin`。 + +--- + +

部署

+ +託管主機的一鍵範本。每個範本都附帶自含的 +Dockerfile,從 npm 拉取 `@agentmemory/agentmemory` 並從官方 +`iiidev/iii` Docker Hub 鏡像複製 iii 引擎二進位 — 無需 +預建 agentmemory 鏡像。持久儲存掛載在 +`/data`;首次啟動 entrypoint 用面向部署調校的設定 +覆寫 npm 捆綁的 iii 設定(原設定繫結 `127.0.0.1`), +讓其繫結 `0.0.0.0` 並使用絕對 `/data` 路徑,產生 +HMAC secret,然後透過 `gosu` 從 `root` 降權到 `node` +再 exec agentmemory CLI。 + +

+ Deploy to fly.io + Deploy to Railway +

+ +Render 的一鍵部署按鈕要求倉庫根有 `render.yaml`,我們刻意保持根目錄整潔。使用 [`deploy/render/`](../deploy/render/README.md) 中文件化的 Render Blueprint 流程,手動指向倉庫內的藍圖。 + +完整設定細節(HMAC 擷取、檢視器 SSH 隧道、輪替、備份、 +成本下限)見 [`deploy/`](../deploy/README.md): + +- [`deploy/fly`](../deploy/fly/README.md) — 單機搭配 + `auto_stop_machines = "stop"`;閒置時最便宜。 +- [`deploy/railway`](../deploy/railway/README.md) — Hobby 方案固定費, + 磁碟區在儀表板中設定。 +- [`deploy/render`](../deploy/render/README.md) — Blueprint 流程, + 付費方案自動磁碟快照。 +- [`deploy/coolify`](../deploy/coolify/README.md) — 透過 [Coolify](https://coolify.io/self-hosted) + 在你自己的 VPS 上自架;同樣的 Docker + Compose 堆疊,主機與資料都歸你所有。 + +僅發布連接埠 `3111`。`3113` 上的檢視器在容器內仍繫結到 +loopback — 每個範本的 README 都文件化了到達它的 +SSH 隧道模式。 + +--- + +

Why agentmemory

+ +每個編碼代理在會話結束時都會忘記一切。你每次會話的前 5 分鐘都浪費在重新解釋技術堆疊上。agentmemory 在背景執行,徹底消除這一點。 + +```text +Session 1: "Add auth to the API" + Agent writes code, runs tests, fixes bugs + agentmemory silently captures every tool use + Session ends -> observations compressed into structured memory + +Session 2: "Now add rate limiting" + Agent already knows: + - Auth uses JWT middleware in src/middleware/auth.ts + - Tests in test/auth.test.ts cover token validation + - You chose jose over jsonwebtoken for Edge compatibility + Zero re-explaining. Starts working immediately. +``` + +### 對比內建代理記憶 + +每個 AI 編碼代理都自帶內建記憶 — Claude Code 有 `MEMORY.md`、Cursor 有 notepad、Cline 有 memory bank。這些像便利貼。agentmemory 是便利貼背後的可搜尋資料庫。 + +| | 內建 (CLAUDE.md) | agentmemory | +|---|---|---| +| 規模 | 200 行上限 | 無限 | +| 搜尋 | 把所有內容載入上下文 | BM25 + 向量 + 圖(僅 top-K) | +| Token 成本 | 240 條觀測達 22K+ | ~1,900 tokens(少 92%) | +| 跨代理 | 每個代理一個檔案 | MCP + REST(任何代理) | +| 協調 | 無 | 租約、訊號、動作、例程 | +| 可觀測性 | 手動讀檔 | 連接埠 3113 即時檢視器 | + +--- + +

How It Works

+ +### 記憶流水線 + +```text +PostToolUse hook fires + -> SHA-256 dedup (5min window) + -> Privacy filter (strip secrets, API keys) + -> Store raw observation + -> LLM compress -> structured facts + concepts + narrative + -> Vector embedding (6 providers + local) + -> Index in BM25 + vector + +Stop / SessionEnd hook fires + -> Summarize session + -> Knowledge graph extraction (if GRAPH_EXTRACTION_ENABLED=true) + -> Slot reflection (if SLOT_REFLECT_ENABLED=true) + +SessionStart hook fires + -> Load project profile (top concepts, files, patterns) + -> Hybrid search (BM25 + vector + graph) + -> Token budget (default: 2000 tokens) + -> Inject into conversation +``` + +### 4 層記憶整合 + +靈感來自人腦處理記憶的方式 — 與睡眠時的記憶整合並無不同。 + +| 層級 | 內容 | 類比 | +|------|------|---------| +| **Working(工作記憶)** | 來自工具使用的原始觀測 | 短期記憶 | +| **Episodic(情節記憶)** | 壓縮後的會話摘要 | 「發生了什麼」 | +| **Semantic(語意記憶)** | 擷取的事實與模式 | 「我知道什麼」 | +| **Procedural(程序記憶)** | 工作流與決策模式 | 「怎麼做」 | + +記憶隨時間衰減(Ebbinghaus 曲線)。頻繁存取的記憶會強化。陳舊記憶會自動清除。矛盾會被偵測並解決。 + +### 捕捉了什麼 + +| Hook | 捕捉內容 | +|------|----------| +| `SessionStart` | 專案路徑、會話 ID | +| `UserPromptSubmit` | 使用者提示(隱私過濾) | +| `PreToolUse` | 檔案存取模式 + 富化上下文 | +| `PostToolUse` | 工具名、輸入、輸出 | +| `PostToolUseFailure` | 錯誤上下文 | +| `PreCompact` | 在壓縮前重新注入記憶 | +| `SubagentStart/Stop` | 子代理生命週期 | +| `Stop` | 會話結束摘要 | +| `SessionEnd` | 會話完成標記 | + +### 關鍵能力 + +| 能力 | 描述 | +|---|---| +| **自動捕捉** | 每次工具使用都透過 hooks 記錄 — 零人工 | +| **語意搜尋** | BM25 + 向量 + 知識圖譜,RRF 融合 | +| **記憶演化** | 版本控制、覆寫關係、關係圖 | +| **自動遺忘** | TTL 過期、矛盾偵測、重要性驅逐 | +| **隱私優先** | API key、secret、`` 標籤儲存前被剝除 | +| **自癒** | 熔斷器、提供者回退鏈、健康監控 | +| **Claude 橋接** | 與 MEMORY.md 雙向同步 | +| **知識圖譜** | 實體擷取 + BFS 走訪 | +| **團隊記憶** | 團隊成員之間的命名空間共享 + 私有 | +| **引用溯源** | 任意記憶追溯到來源觀測 | +| **Git 快照** | 記憶狀態的版本、回滾、diff | + +--- + + + +三路檢索結合三種訊號: + +| 流 | 功用 | 何時啟用 | +|---|---|---| +| **BM25** | 詞幹化關鍵字比對 + 同義詞擴展 | 始終啟用 | +| **Vector(向量)** | 稠密嵌入上的餘弦相似度 | 已設定嵌入提供者 | +| **Graph(圖)** | 透過實體比對進行知識圖譜走訪 | 查詢中偵測到實體 | + +透過 Reciprocal Rank Fusion (RRF, k=60) 融合,並按會話多樣化(每個會話最多 3 個結果)。 + +BM25 開箱即用支援希臘文、西里爾文、希伯來文、阿拉伯文和帶音標拉丁文的分詞。對於中文/日文/韓文記憶,安裝可選分詞器(`npm install @node-rs/jieba tiny-segmenter`)以把 CJK 串切分為詞級 token;若未安裝,agentmemory 會軟回退到整串分詞並在 stderr 印出一次性提示。 + +### 嵌入提供者 + +agentmemory 自動偵測你的提供者。為獲得最佳效果,安裝本地嵌入(免費): + +```bash +npm install @xenova/transformers +``` + +| 提供者 | 模型 | 成本 | 備註 | +|---|---|---|---| +| **本地(推薦)** | `all-MiniLM-L6-v2` | 免費 | 離線,比僅 BM25 召回率高 +8pp | +| Gemini | `gemini-embedding-001` | 免費層 | 100+ 語言,768/1536/3072 維 (MRL),2048-token 輸入。取代 `text-embedding-004`([已棄用,2026 年 1 月 14 日下線](https://ai.google.dev/gemini-api/docs/deprecations)) | +| OpenAI | `text-embedding-3-small` | $0.02/1M | 最高品質 | +| Voyage AI | `voyage-code-3` | 付費 | 針對程式碼最佳化 | +| Cohere | `embed-english-v3.0` | 免費試用 | 通用 | +| OpenRouter | 任意模型 | 視情況 | 多模型代理 | + +--- + +

MCP Server

+ +53 個工具、6 個資源、3 個提示、4 個 skills — 任何代理可用的最全面 MCP 記憶工具組。 + +> **MCP shim 對比完整伺服器:** 已發布的 `@agentmemory/mcp` 套件是一個薄 shim。**只有當它能透過 `AGENTMEMORY_URL` 連通執行中的 agentmemory 伺服器**(代理模式)時,才暴露完整的 51 工具表面。在沒有可達伺服器的情況下,shim 回退到 7 工具的本地集合(`memory_save`、`memory_recall`、`memory_smart_search`、`memory_sessions`、`memory_export`、`memory_audit`、`memory_governance_delete`)。`AGENTMEMORY_TOOLS=core|all` 環境變數是*伺服器端*旗標 — 在 shim 的 `env` 區塊中設定無效。若在 Cursor / OpenCode / Gemini CLI 中只看到 7 個工具,啟動 `npx @agentmemory/agentmemory`(或 Docker 堆疊)並設定 `AGENTMEMORY_URL=http://localhost:3111`。 + +### 51 個工具 + +
+核心工具(始終可用) + +| 工具 | 描述 | +|------|-------------| +| `memory_recall` | 搜尋過去的觀測 | +| `memory_compress_file` | 在保留結構的同時壓縮 markdown 檔 | +| `memory_save` | 儲存洞察、決策或模式 | +| `memory_patterns` | 偵測反覆出現的模式 | +| `memory_smart_search` | 混合語意 + 關鍵字搜尋 | +| `memory_file_history` | 關於特定檔案的過去觀測 | +| `memory_sessions` | 列出最近的會話 | +| `memory_timeline` | 按時間排列的觀測 | +| `memory_profile` | 專案檔案(概念、檔案、模式) | +| `memory_export` | 匯出所有記憶資料 | +| `memory_relations` | 查詢關係圖 | + +
+ +
+擴展工具(共 51 — 設定 AGENTMEMORY_TOOLS=all) + +| 工具 | 描述 | +|------|-------------| +| `memory_patterns` | 偵測反覆出現的模式 | +| `memory_timeline` | 按時間排列的觀測 | +| `memory_relations` | 查詢關係圖 | +| `memory_graph_query` | 知識圖譜走訪 | +| `memory_consolidate` | 執行 4 層整合 | +| `memory_claude_bridge_sync` | 與 MEMORY.md 同步 | +| `memory_team_share` | 與團隊成員共享 | +| `memory_team_feed` | 最近共享條目 | +| `memory_audit` | 操作稽核軌跡 | +| `memory_governance_delete` | 帶稽核軌跡的刪除 | +| `memory_snapshot_create` | Git 版本快照 | +| `memory_action_create` | 建立帶相依性的工作項 | +| `memory_action_update` | 更新動作狀態 | +| `memory_frontier` | 依優先序排序的未阻塞動作 | +| `memory_next` | 單一最重要的下一個動作 | +| `memory_lease` | 獨佔動作租約(多代理) | +| `memory_routine_run` | 實例化工作流例程 | +| `memory_signal_send` | 代理之間的訊息 | +| `memory_signal_read` | 帶回執讀取訊息 | +| `memory_checkpoint` | 外部條件閘門 | +| `memory_mesh_sync` | 實例之間 P2P 同步 | +| `memory_sentinel_create` | 事件驅動監視器 | +| `memory_sentinel_trigger` | 外部觸發哨兵 | +| `memory_sketch_create` | 暫時動作圖 | +| `memory_sketch_promote` | 提升為永久 | +| `memory_crystallize` | 緊湊化動作鏈 | +| `memory_diagnose` | 健康檢查 | +| `memory_heal` | 自動修復卡住的狀態 | +| `memory_facet_tag` | 維度:值 標籤 | +| `memory_facet_query` | 依 facet 標籤查詢 | +| `memory_verify` | 追溯來源 | + +
+ +### 6 個資源 · 3 個提示 · 4 個 Skills + +| 類型 | 名稱 | 描述 | +|------|------|-------------| +| Resource | `agentmemory://status` | 健康、會話數、記憶數 | +| Resource | `agentmemory://project/{name}/profile` | 專案層級智慧 | +| Resource | `agentmemory://memories/latest` | 最新 10 條活躍記憶 | +| Resource | `agentmemory://graph/stats` | 知識圖譜統計 | +| Prompt | `recall_context` | 搜尋並回傳上下文訊息 | +| Prompt | `session_handoff` | 代理之間的交接資料 | +| Prompt | `detect_patterns` | 分析反覆出現的模式 | +| Skill | `/recall` | 搜尋記憶 | +| Skill | `/remember` | 儲存到長期記憶 | +| Skill | `/session-history` | 最近的會話摘要 | +| Skill | `/forget` | 刪除觀測/會話 | + +### 獨立 MCP + +無需完整伺服器即可執行 — 適用於任何 MCP 用戶端。以下兩種都可以: + +```bash +npx -y @agentmemory/agentmemory mcp # 標準指令(始終可用) +npx -y @agentmemory/mcp # shim 套件別名 +``` + +或新增到你的代理的 MCP 設定: + +大多數代理(Cursor、Claude Desktop、Cline、Roo Code、Windsurf、Gemini CLI): +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "http://localhost:3111" + } + } + } +} +``` + +把 `agentmemory` 條目合併到你的宿主既有的 `mcpServers` 物件中,而非取代檔案。對於無法存取宿主 `localhost` 的沙箱用戶端,在 env 區塊中加入 `"AGENTMEMORY_FORCE_PROXY": "1"`,並把 `AGENTMEMORY_URL` 設為沙箱能到達的路由。 + +OpenCode (`opencode.json`): +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + }, + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +從倉庫複製外掛檔: +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +cp plugin/opencode/commands/*.md ~/.config/opencode/commands/ +``` + +--- + +

Real-Time Viewer

+ +在連接埠 `3113` 自動啟動。即時觀測流、會話瀏覽器、記憶瀏覽器、知識圖譜視覺化和健康儀表板。 + +```bash +open http://localhost:3113 +``` + +檢視器伺服器預設繫結 `127.0.0.1`。REST 提供的 `/agentmemory/viewer` 端點遵循正常的 `AGENTMEMORY_SECRET` bearer-token 規則。CSP 標頭使用每回應 script nonce 並停用行內處理常式屬性(`script-src-attr 'none'`)。 + +--- + +

iii Console

+ +`:3113` 上的檢視器展示你的代理**記住了什麼**。[iii 主控台](https://iii.dev/docs/console) 展示你的代理**做了什麼** — 每個記憶操作都是 OpenTelemetry trace,每個 KV 條目都可編輯,每個函式都可呼叫,每個串流都可掛載。同一記憶的兩個視窗:一個面向產品,一個面向引擎。 + +觀察一次 `memory_smart_search` 觸發,在瀑布圖中看到 BM25 掃描 → 嵌入查找 → RRF 融合 → 重新排序器。在 KV 瀏覽器中編輯卡住的整合計時器。用調整後的負載重播一個 `PostToolUse` hook。釘選 WebSocket 串流,即時觀察觀測落地。 + +agentmemory 免費提供這一切,因為每個函式、觸發器、狀態範圍、串流都是 iii 原語 — 沒有自訂、沒有需要插樁的地方。 + +

+ iii console Workers page — connected workers including agentmemory instances with live function counts and runtime metadata +
+ Workers 頁面:每個已連接 worker — 包括 agentmemory 本身 — 顯示 PID、函式數、執行階段和最後在線時間。 +

+ +**已經裝好了。** 主控台隨 `iii` 一同發布 — 無需獨立安裝器。 + +**與 agentmemory 並行啟動:** + +```bash +# agentmemory 檢視器佔用連接埠 3113,所以在 3114 執行主控台。 +# 引擎 REST (3111)、WebSocket (3112)、bridge (49134) 預設值與 agentmemory 相符。 +iii console --port 3114 +``` + +然後打開 `http://localhost:3114`。加 `--enable-flow` 開啟實驗性架構圖頁面。 + +僅在你已移動引擎端點時才覆寫: + +```bash +iii console --port 3114 \ + --engine-port 3111 \ + --ws-port 3112 \ + --bridge-port 49134 +``` + +**主控台能做什麼:** + +| 頁面 | 用途 | +|------|-----------| +| **Workers** | 查看每個已連接 worker 及其即時指標 — 包括 agentmemory worker 本身。 | +| **Functions** | 直接以 JSON 負載呼叫 agentmemory 的任何函式 — 測試 `memory.recall`、`memory.consolidate`、`graph.query` 無需接入用戶端。 | +| **Triggers** | 重播 HTTP、cron、事件和狀態觸發器 — 手動觸發整合 cron、重試 HTTP 路由、發出狀態變更。 | +| **States** | 完整 CRUD 的 KV 瀏覽器 — 會話、記憶槽位、生命週期計時器、嵌入索引 — 就地編輯值。 | +| **Streams** | 記憶寫入、hook 事件和觀測更新流經 iii 串流時的即時 WebSocket 監視器。 | +| **Queues** | 持久佇列主題 + 死信管理。重播或捨棄失敗的嵌入/壓縮工作。 | +| **Traces** | OpenTelemetry 瀑布/火焰/服務分解視圖。按 `trace_id` 過濾,精確查看單次 `memory.search` 產生了哪些函式、DB 呼叫和嵌入請求。 | +| **Logs** | 結構化 OTEL 日誌,過濾並與 trace/span ID 關聯。 | +| **Config** | 執行階段設定 — 看到引擎正在使用的 workers、提供者和連接埠。 | +| **Flow** | (選用,`--enable-flow`)每個 worker、觸發器和串流的互動式架構圖。 | + +

+ iii console trace waterfall view showing per-span duration +
+ Traces:每個記憶操作的瀑布/火焰/服務分解。 +

+ +**Traces 已開啟:** + +`iii-config.yaml` 出廠啟用 `iii-observability` worker(`exporter: memory`、`sampling_ratio: 1.0`、指標 + 日誌)。無需額外設定 — agentmemory 啟動那一刻,每個記憶操作都會發出一個 trace span 和一個主控台可讀的結構化日誌。 + +若你想改為匯出到 Jaeger/Honeycomb/Grafana Tempo,把 `exporter: memory` 改為 `exporter: otlp` 並依 iii 的可觀測性文件設定收集器端點。 + +> **提醒:** 主控台本身未強制驗證 — 保持其繫結 `127.0.0.1`(預設)並永遠不要對外暴露。 + +--- + +

Powered by iii

+ +agentmemory **本身就是一個執行中的 [iii](https://iii.dev) 實例**。函式、觸發器、KV 狀態、串流、OTEL traces — 全部都是 iii 原語。你沒有安裝 Postgres、Redis、Express、pm2 或 Prometheus,因為 iii 取代了它們。 + +這代表多一條指令就能為 agentmemory 增加一整套新能力。 + +### 一條指令擴展 agentmemory + +```bash +iii worker add iii-pubsub # 把記憶寫入扇出到每個連接的實例 +iii worker add iii-cron # 排程整合、衰減掃描、快照輪替 +iii worker add iii-queue # 嵌入 + 壓縮工作的持久重試 +iii worker add iii-observability # 每個記憶操作的 OTEL traces(預設開啟) +iii worker add iii-sandbox # 在隔離 microVM 內執行召回到的程式碼 +iii worker add iii-database # 切換 SQL 後端的狀態適配器 +iii worker add mcp # 在 agentmemory 的 MCP 旁開設通用 MCP 宿主 +``` + +每個 `iii worker add` 都會把新的函式和觸發器註冊到 agentmemory 正在執行的同一引擎中。檢視器和主控台立即接收 — 無需重新載入、無需新整合、無需新容器。 + +| `iii worker add` | 在 agentmemory 上獲得的額外能力 | +|---|---| +| [`iii-pubsub`](https://workers.iii.dev/workers/iii-pubsub) | 多實例記憶:每次 `remember` 扇出,每次 `search` 讀取聯集 | +| [`iii-cron`](https://workers.iii.dev/workers/iii-cron) | 排程生命週期 — 夜間整合、週快照、按固定時鐘衰減 | +| [`iii-queue`](https://workers.iii.dev/workers/iii-queue) | 持久重試:失敗的嵌入 + 壓縮工作在重啟後存活,無觀測遺失 | +| [`iii-observability`](https://workers.iii.dev/workers/iii-observability) | 每個函式的 OTEL traces、指標、日誌 — 從第一天起就接入 `iii-config.yaml` | +| [`iii-sandbox`](https://workers.iii.dev/workers/iii-sandbox) | `memory_recall` 出來的程式碼在一次性 VM 中執行,不在你的 shell 中 | +| [`iii-database`](https://workers.iii.dev/workers/iii-database) | 當預設的記憶體 KV 不夠用時,SQL 後端狀態適配器 | +| [`mcp`](https://workers.iii.dev/workers/mcp) | 在 agentmemory 的旁邊架設額外 MCP 伺服器,共享同一引擎 | + +完整登錄表:[workers.iii.dev](https://workers.iii.dev)。那裡的每個 worker 都透過 agentmemory 所用的同樣原語組合 — 而你已經擁有的 agentmemory 本身就是其中之一。 + +### iii 取代了什麼 + +| 傳統堆疊 | agentmemory 使用 | +|---|---| +| Express.js / Fastify | iii HTTP Triggers | +| SQLite / Postgres + pgvector | iii KV State + 記憶體向量索引 | +| SSE / Socket.io | iii Streams (WebSocket) | +| pm2 / systemd | iii engine worker 監管 | +| Prometheus / Grafana | iii OTEL + 健康監控 | +| 自訂外掛系統 | `iii worker add ` | + +**118 個原始檔 · ~21,800 行程式碼 · 950+ 測試 · 123 個函式 · 34 個 KV 範圍** — 全部基於三種原語。沒有 `agentmemory plugin install`。外掛系統就是 iii 本身。 + +--- + +

Configuration

+ +### LLM 提供者 + +agentmemory 從你的環境自動偵測。預設情況下,除非你設定提供者或明確啟用 Claude 訂閱回退,否則不會發起 LLM 呼叫。 + +| 提供者 | 設定 | 備註 | +|----------|--------|-------| +| **No-op(預設)** | 無需設定 | LLM 驅動的 compress/summarize 被停用。合成 BM25 壓縮 + 召回仍可用。若你以前依賴 Claude 訂閱回退,請見下面的 `AGENTMEMORY_ALLOW_AGENT_SDK`。 | +| Anthropic API | `ANTHROPIC_API_KEY` | 依 token 計費 | +| MiniMax | `MINIMAX_API_KEY` | Anthropic 相容 | +| Gemini | `GEMINI_API_KEY` | 同時啟用嵌入 | +| OpenRouter | `OPENROUTER_API_KEY` | 任意模型 | +| Claude 訂閱回退 | `AGENTMEMORY_ALLOW_AGENT_SDK=true` | 僅按需啟用。會衍生 `@anthropic-ai/claude-agent-sdk` 會話 — 曾導致無限 Stop-hook 遞迴故不再預設。 | + +### 成本感知的模型選擇 + +背景壓縮在每次觀測時執行,模型選擇會顯著影響月支出。擷取的工作負載資料:635 次請求 / 888K tokens / 35 小時活躍使用,基於 2026-05-23 OpenRouter 定價對三個模型評測。 + +| 等級 | 模型 | 輸入 / 1M | 輸出 / 1M | 35 小時擷取工作負載成本 | 備註 | +|------|-------|------------|-------------|---------------------------|-------| +| 推薦 | `deepseek/deepseek-v4-pro` | $0.435 | $0.87 | ~$0.46 | 壓縮 + 摘要品質穩定,比 Sonnet 便宜 ~10×。 | +| 推薦 | `deepseek/deepseek-chat` | $0.27 | $1.10 | ~$0.40 | 略舊但仍勝任僅壓縮工作負載。 | +| 推薦 | `qwen/qwen3-coder` | $0.45 | $1.80 | ~$0.55 | 若你的會話多為程式碼,程式碼推理能力強。 | +| 高階 | `anthropic/claude-sonnet-4.6` | $3.00 | $15.00 | ~$5.02 | 品質高但對長期背景工作來說成本昂貴。 | +| 高階 | `openai/gpt-4o` | $2.50 | $10.00 | ~$4.20 | 與 Sonnet 同檔。 | +| 避免 | `anthropic/claude-opus-4.6` | $15.00 | $75.00 | ~$25+ | 推理級模型;用於壓縮屬於巨額超支。 | + +當 `OPENROUTER_MODEL` 比對高階層模式時,agentmemory 會印出執行階段警告。在做出知情選擇後,設定 `AGENTMEMORY_SUPPRESS_COST_WARNING=1` 來消音。 + +記憶工作的品質-成本權衡:壓縮是品質門檻相對寬鬆的摘要任務(代理重新閱讀摘要,而非使用者)。DeepSeek-V4-Pro / Qwen3-Coder 在該任務上與 Sonnet 誤差極小,而成本約低 10×。把高階層模型留給你直接閱讀的查詢。 + +來源:[OpenRouter Sonnet 4.6 定價](https://openrouter.ai/anthropic/claude-sonnet-4.6/pricing)、[DeepSeek V4 Pro](https://openrouter.ai/deepseek/deepseek-v4-pro)、[DeepSeek 定價說明](https://api-docs.deepseek.com/quick_start/pricing/)。 + +### 多代理記憶(`AGENT_ID` + `AGENTMEMORY_AGENT_SCOPE`) + +在多個角色共享一台 agentmemory 伺服器的多代理設置中(architect / developer / reviewer / researcher / support-agent),`AGENT_ID` 給每次寫入打上發起角色的標籤。`AGENTMEMORY_AGENT_SCOPE` 控制召回是否依該標籤過濾。 + +```env +TEAM_ID=company +USER_ID=engineering-team +AGENT_ID=architect +AGENTMEMORY_AGENT_SCOPE=isolated # 選填;預設 "shared" +``` + +兩種模式: + +| 模式 | 標記寫入 | 過濾召回 | 何時使用 | +|------|------------|---------------|-------------| +| `shared`(預設) | 是 | 否 | 跨代理共享上下文且帶稽核軌跡。Architect 能看到 developer 記下了什麼,但每條記錄都標明發言者。 | +| `isolated` | 是 | 是 | 嚴格隔離。Architect 永遠不會看到 developer 的觀測/記憶/會話。 | + +設定 `AGENT_ID` 後會被標記的內容:`Session.agentId`、`RawObservation.agentId`、`CompressedObservation.agentId`、`Memory.agentId`。角色從 `api::session::start` → `mem::observe` → `mem::compress` → KV 流轉。 + +isolated 模式下被過濾的內容:`mem::smart-search`、`/agentmemory/memories`、`/agentmemory/observations`、`/agentmemory/sessions`。每個端點都接受 `?agentId=` 來依請求覆寫,以及 `?agentId=*` 來完全跳過環境範圍。`/memories` 還接受 `?includeOrphans=true` 來浮現 `agentId` 為 undefined 的 pre-AGENT_ID 記憶。 + +SDK / REST 層的依呼叫覆寫:每個變更端點(`/session/start`、`/remember`)都接受請求體中的 `agentId` 欄位,勝過環境變數。對於在一個伺服器行程中路由多角色的執行階段很有用。 + +當 `AGENT_ID` 未設定時,記憶保持無範圍(舊行為,無標籤、無過濾)。 + +### 連接埠 + +agentmemory + iii-engine 預設繫結四個連接埠。若重啟失敗並顯示 `port in use`,這張表告訴你該查找什麼行程。 + +| 連接埠 | 行程 | 用途 | 環境覆寫 | +|------|---------|---------|--------------| +| `3111` | agentmemory | REST API + MCP HTTP + `/agentmemory/health` + `/agentmemory/livez` | `III_REST_PORT` | +| `3112` | iii-engine | 內部串流 worker(由 agentmemory + 檢視器消費) | `III_STREAMS_PORT` | +| `3113` | agentmemory | 即時檢視器(`http://localhost:3113`) | `AGENTMEMORY_VIEWER_PORT` | +| `49134` | iii-engine | WebSocket — workers 在此註冊,OTel 遙測在此流過 | `III_ENGINE_URL`(完整 URL,預設 `ws://localhost:49134`) | + +當機後連接埠仍被佔用時的陳舊行程清理: + +```bash +# macOS / Linux — 找出每個連接埠上的行程並 kill 掉 +lsof -i :3111,3112,3113,49134 +pkill -f agentmemory || true +pkill -f 'iii ' || true + +# Windows +netstat -ano | findstr ":3111 :3112 :3113 :49134" +taskkill /F /PID +``` + +`agentmemory stop` 在優雅關閉時乾淨地回收 worker 和 engine pidfile。上述手動清理僅針對當機後兩個 pidfile 都未留下的情況。 + +### 設定檔 + +把 agentmemory 執行階段設定放到 `~/.agentmemory/.env`,而非在每個 shell 中 export 變數。若檢視器顯示像 `export ANTHROPIC_API_KEY=...` 這樣的設定提示,把它複製到該檔案作為 `ANTHROPIC_API_KEY=...`(去掉 `export` 前綴),然後重啟 agentmemory。 + +行程環境變數仍然有效,優先序高於檔案中的值。 + +在 Windows 上,同一檔案位於 `%USERPROFILE%\.agentmemory\.env`: + +```powershell +New-Item -ItemType Directory -Force $HOME\.agentmemory +notepad $HOME\.agentmemory\.env +``` + +要用 Claude Code Pro/Max 訂閱而非 API key 測試,明確啟用: + +```env +AGENTMEMORY_ALLOW_AGENT_SDK=true +AGENTMEMORY_AUTO_COMPRESS=true +``` + +若想開啟圖或整合特性,在同一檔案中打開: + +```env +GRAPH_EXTRACTION_ENABLED=true +CONSOLIDATION_ENABLED=true +``` + +### 環境變數 + +建立 `~/.agentmemory/.env`: + +```env +# LLM provider (pick one — default is the no-op provider: no LLM calls) +# ANTHROPIC_API_KEY=sk-ant-... +# ANTHROPIC_BASE_URL=... # Optional: Anthropic-compatible proxy / Azure +# GEMINI_API_KEY=... +# OPENROUTER_API_KEY=... +# MINIMAX_API_KEY=... +# OPENAI_API_KEY=*** # NOTE: this same key auto-activates BOTH the +# # OpenAI LLM provider (here) AND the OpenAI +# # embedding provider (further below). Set +# # OPENAI_API_KEY_FOR_LLM=false to scope it +# # to embeddings only. +# OPENAI_BASE_URL=https://api.openai.com # Optional: override for Azure / vLLM / LM Studio / proxies +# # Azure: https://.openai.azure.com/openai/deployments/ +# # 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_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 +# # prefer the global AGENTMEMORY_LLM_TIMEOUT_MS below. +# OPENAI_REASONING_EFFORT=none # Optional: "low" | "medium" | "high" | "none" +# # Honored only by OpenAI's reasoning models (o1, o3, +# # gpt-*-reasoning) and providers that mirror that +# # schema (Ollama Cloud thinking models). Standard +# # chat models reject this field with 400. Set to +# # "none" for thinking models that return reasoning +# # but no content. +# OPENAI_API_KEY_FOR_LLM=false # Optional: set to false to skip OpenAI auto-detection +# # for LLM (useful if you only want OpenAI for embeddings) +# Opt-in Claude-subscription fallback (spawns @anthropic-ai/claude-agent-sdk); +# leave OFF unless you understand the Stop-hook recursion risk: +# AGENTMEMORY_ALLOW_AGENT_SDK=true + +# Embedding provider (auto-detected, or override) +# EMBEDDING_PROVIDER=local +# VOYAGE_API_KEY=... +# OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com # Override for Azure / vLLM / LM Studio / proxies +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small +# OPENAI_EMBEDDING_DIMENSIONS=1536 # Required when the model is not in the known-models table + +# Outbound LLM / embedding timeout +# AGENTMEMORY_LLM_TIMEOUT_MS=60000 # Default: 60 000 ms (60 s). Applies to every + # raw-fetch provider (Gemini, OpenRouter, MiniMax, + # OpenAI LLM, OpenAI/Cohere/Voyage/OpenRouter + # embedding). For the OpenAI LLM path, the + # OpenAI-scoped OPENAI_TIMEOUT_MS alias (above) + # takes precedence when set, for back-compat + # with v0.9.17. + # Increase for slow networks or large batch calls; + # decrease to fail-fast on rate-limit holds. + +# Search tuning +# BM25_WEIGHT=0.4 +# VECTOR_WEIGHT=0.6 +# TOKEN_BUDGET=2000 + +# Auth +# AGENTMEMORY_SECRET=your-secret + +# Ports (defaults: 3111 API, 3113 viewer) +# III_REST_PORT=3111 + +# Features +# AGENTMEMORY_AUTO_COMPRESS=false # OFF by default. When on, + # every PostToolUse hook calls your + # LLM provider to compress the + # observation — expect significant + # token spend on active sessions. +# AGENTMEMORY_SLOTS=false # OFF by default. Editable pinned + # memory slots — persona, + # user_preferences, tool_guidelines, + # project_context, guidance, + # pending_items, session_patterns, + # self_notes. Size-limited; agent + # edits via memory_slot_* tools. + # Pinned slots addressable for + # SessionStart injection. +# AGENTMEMORY_REFLECT=false # OFF by default. Requires SLOTS=on. + # Stop hook fires mem::slot-reflect: + # scans recent observations, auto- + # appends TODOs to pending_items, + # counts patterns in + # session_patterns, records touched + # files in project_context. Fire- + # and-forget; does not block. +# AGENTMEMORY_INJECT_CONTEXT=false # OFF by default. When on: + # - SessionStart may inject ~1-2K + # chars of project context into + # the first turn of each session + # (this is what actually reaches + # the model — Claude Code treats + # SessionStart stdout as context) + # - PreToolUse fires /agentmemory/enrich + # on every file-touching tool call + # (resource cleanup, not a token + # fix — PreToolUse stdout is debug + # log only per Claude Code docs) + # Observations are still captured via + # PostToolUse regardless of this flag. +# GRAPH_EXTRACTION_ENABLED=false +# CONSOLIDATION_ENABLED=true +# LESSON_DECAY_ENABLED=true +# OBSIDIAN_AUTO_EXPORT=false +# AGENTMEMORY_EXPORT_ROOT=~/.agentmemory +# CLAUDE_MEMORY_BRIDGE=false +# SNAPSHOT_ENABLED=false + +# Team +# TEAM_ID= +# USER_ID= +# TEAM_MODE=private + +# Tool visibility: "core" (8 tools) or "all" (51 tools) +# AGENTMEMORY_TOOLS=core +``` + +--- + +

API

+ +連接埠 `3111` 上的 124 個端點。REST API 預設繫結 `127.0.0.1`。當 `AGENTMEMORY_SECRET` 已設定時,受保護端點需要 `Authorization: Bearer `,網狀同步端點要求兩端都設定 `AGENTMEMORY_SECRET`。 + +
+關鍵端點 + +| 方法 | 路徑 | 描述 | +|--------|------|-------------| +| `GET` | `/agentmemory/health` | 健康檢查(始終公開) | +| `POST` | `/agentmemory/session/start` | 開始會話 + 取得上下文 | +| `POST` | `/agentmemory/session/end` | 結束會話 | +| `POST` | `/agentmemory/observe` | 擷取觀測 | +| `POST` | `/agentmemory/smart-search` | 混合搜尋 | +| `POST` | `/agentmemory/context` | 產生上下文 | +| `POST` | `/agentmemory/remember` | 儲存到長期記憶 | +| `POST` | `/agentmemory/forget` | 刪除觀測 | +| `POST` | `/agentmemory/enrich` | 檔案上下文 + 記憶 + bugs | +| `GET` | `/agentmemory/profile` | 專案檔案 | +| `GET` | `/agentmemory/export` | 匯出所有資料 | +| `POST` | `/agentmemory/import` | 從 JSON 匯入 | +| `POST` | `/agentmemory/graph/query` | 知識圖譜查詢 | +| `POST` | `/agentmemory/team/share` | 與團隊共享 | +| `GET` | `/agentmemory/audit` | 稽核軌跡 | + +完整端點列表:[`src/triggers/api.ts`](../src/triggers/api.ts) + +
+ +--- + +

Development

+ +```bash +npm run dev # 熱重新載入 +npm run build # 生產建置 +npm test # 950+ 測試 +npm run test:integration # API 測試(需要服務執行中) +``` + +**先決條件:** Node.js >= 20、[iii-engine](https://iii.dev/docs) 或 Docker + +

License

+ +[Apache-2.0](../LICENSE) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f77f371 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,92 @@ +# Roadmap + +This is agentmemory's public 12-month roadmap. It covers Q2 2026 through Q1 2027. The roadmap is the source of truth for where the project is heading; anything significant that lands in main should trace back to an item here or a ratified issue. + +Items shift as evidence changes. Each quarter we publish a short retrospective on what landed, what slipped, and why — attached to the release notes. + +## How to read this + +- **Shipped** — landed in main and tagged in a release. +- **Active** — in-flight, has an open PR or issue owner. +- **Planned** — accepted scope for the quarter, not started. +- **Candidate** — under consideration, may defer. + +Anything not on this list that a contributor wants to pursue is welcome — open an issue labeled `roadmap` and it gets triaged against the quarterly theme. + +## Themes + +- **Q2 2026 — Depth.** Multimodal memory, more connectors, close out backlog from the v0.9 cycle. +- **Q3 2026 — Breadth.** Hook parity across more agents, community expansion, OpenSSF best-practices alignment. +- **Q4 2026 — Trust.** Enterprise features — SSO, audit export, RBAC, long-running deployment story. +- **Q1 2027 — v1.0.** Stability, LTS branch, semver freeze on the REST + MCP surface. + +## Q2 2026 — Depth (April – June) + +### Shipped so far in this quarter +- [x] iii console docs in README with vendored screenshots (#157) +- [x] Health severity gated on RSS floor (#158 / PR #160) +- [x] Standalone MCP proxies to the running server (#159 / PR #161) +- [x] Audit coverage for `mem::forget` + audit policy doc (#125 / PR #162) +- [x] `@agentmemory/fs-watcher` filesystem connector (#62 / PR #163) +- [x] Next.js website on Vercel (PR #164) +- [x] CI publishes all three npm packages on release (PR #166) + +### Active +- [ ] **Multimodal memory** — content-addressed image store, vision-prompt compression, disk quota + refcount on eviction (#64, PR #111) +- [ ] **Governance baseline** — this file, plus `GOVERNANCE.md`, `CONTRIBUTING.md`, `MAINTAINERS.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` + +### Planned +- [ ] **GitHub connector** (`@agentmemory/github-watcher`) — sync issues, PRs, discussions as observations. Shares the `POST /agentmemory/observe` wire format with the filesystem connector. +- [x] **OpenCode hook bus** (#156) — wired 22 hooks covering all 12 Claude Code hook types: session lifecycle (create/idle/status/compacted/update/diff/delete/error), messages & prompts (chat.message, message.updated user+assistant, message.removed), tool capture (before + rich ToolPart lifecycle in message.part.updated), memory injection (context + enrich via system.transform), part tracking (subtask, step-finish, reasoning, file, patch, compaction, agent, retry), file enrichment pipeline (stash via tool.execute.before + file.edited + file parts), permissions (updated + replied), task tracking (todo.updated w/ priority), commands (command.executed), config & model tracking (config + chat.params). Plus 2 slash commands (recall/remember). See `plugin/opencode/`. +- [ ] **Session replay UI** in the real-time viewer — scrub the timeline, inspect per-observation payloads. +- [ ] **Benchmark harness in CI** — keep the 95.2% R@5 number honest across releases by re-running LongMemEval-S on every minor tag. + +## Q3 2026 — Breadth (July – September) + +### Planned +- [ ] **Additional maintainer onboarding** — at least one Maintainer from a different organization added via the process in `GOVERNANCE.md`. This is a prerequisite for advancing past the foundation's Growth Stage. +- [ ] **Slack / Discord connector** — third source in the connector family. +- [ ] **OpenSSF Scorecard** — enroll, reach a Silver-equivalent score. Badged in the README. +- [ ] **Hermes integration hardening** — reach parity with the OpenClaw plugin surface (session lifecycle + tool-use hooks). +- [ ] **Knowledge graph query language** — small DSL on top of `/agentmemory/graph` for multi-hop questions. +- [ ] **First conference talk** — submit to KubeCon / LlamaCon / similar. + +### Candidate +- Cross-agent shared memory namespace. Currently each agent installs its own instance. This would let a Claude Code session and a Cursor session recall each other's observations via a shared mesh node. + +## Q4 2026 — Trust (October – December) + +### Planned +- [ ] **SSO gateway** — accept OIDC in front of the REST surface for team deployments. +- [ ] **Audit log export** — streamable tail to S3 / Loki / stdout for compliance pipelines. +- [ ] **RBAC on memory scope** — `project:read`, `project:write`, `governance:delete` role set. +- [ ] **Long-running deployment guide** — first-class Docker, systemd unit, and launchd plist. +- [ ] **Performance SLO** — publish p50/p95 recall latency targets, enforce via the benchmark harness. +- [ ] **Security audit** — external review of the REST surface + mesh-sync path. Fund through LF if foundation acceptance lands before end of quarter. + +### Candidate +- Agent-to-agent memory handoff protocol — standardize what one agent can inherit from another's memory, complementing MCP. + +## Q1 2027 — v1.0 (January – March) + +### Planned +- [ ] **REST + MCP surface freeze.** Any break requires a major-version tag per `GOVERNANCE.md`. +- [ ] **LTS branch `v1.x`** — 12-month security-fix commitment. +- [ ] **v1.0 release** — full documentation pass, all roadmap items from prior quarters either shipped or formally deferred. +- [ ] **Foundation membership** — Growth → Impact stage application if adoption + maintainer diversity metrics justify. + +### Candidate +- Hosted reference instance for the community to benchmark against. +- Reference implementation in a second language (Rust or Go) for the MCP server — would expand the set of runtimes that can host agentmemory. + +## Out of scope + +For transparency, these are deliberately *not* on the roadmap: + +- A cloud-hosted agentmemory SaaS. +- Billing, subscription tiers, commercial licensing beyond Apache-2.0. +- Agent frameworks themselves — agentmemory is a dependency, not a replacement for the agent runtime. + +## Feedback + +Anything on this list you disagree with, or think should move up / down — open an issue tagged `roadmap`. Quarterly themes are revisited with every quarterly retrospective. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a24b6e9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,85 @@ +# Security Policy + +## Reporting a vulnerability + +**Do not open a public GitHub issue for a suspected vulnerability.** + +Use one of: + +- **GitHub Security Advisories (preferred)** — private report form at . GitHub routes the report to the Maintainers, assigns a GHSA identifier, and keeps you in a private thread until the fix ships. All sensitive details (stack traces, credentials, exploit payloads) stay end-to-end within GitHub's security infrastructure — use this channel whenever possible. +- **Encrypted email (fallback)** — if GitHub is unavailable or the issue cannot be described in the GHSA form, send an encrypted message to `ghumare64@gmail.com` with subject `agentmemory security`. Encrypt with the Maintainer public keys published at (PGP) and (SSH for verification); attach your own public key so we can reply encrypted. Plaintext email is accepted only as a last resort — prefer GHSA. + +Include, at minimum: + +- agentmemory version (`npm view @agentmemory/agentmemory version` against your install). +- The affected surface — REST endpoint, MCP tool, hook, CLI flag, or filesystem layout. +- A minimal reproduction — prefer one curl invocation or one MCP tool call plus the environment state required. +- Impact, in your own words. + +## What we do with it + +1. **Acknowledge** within 72 hours (target: 24). +2. **Triage** — confirm reproduction, assign a severity using CVSS 3.1, and give you a rough timeline. +3. **Fix** in a private branch. Draft a GitHub Security Advisory with the patched version, CWE, CVSS vector, affected versions, and attribution to you (unless you prefer anonymity). +4. **Coordinate disclosure** — we agree a disclosure date with you. Default window is 30 days from acknowledgment for straightforward vulnerabilities, up to 90 days for ones that need a deep refactor. +5. **Publish** — release the patched version on npm, publish the advisory, update `CHANGELOG.md` under a `### Security` section for the release, notify downstream scanners. + +## Supported versions + +| Version | Security fixes? | +|-|-| +| Latest minor (currently `0.9.x`) | Yes | +| Previous minor (currently `0.8.x`) | Critical / High severity only, for 90 days after a new minor is released | +| Older | No | + +At v1.0 this policy switches to a stated LTS window per the roadmap. + +## Scope + +In scope: + +- The `@agentmemory/agentmemory` server (REST + MCP surface, hook handlers, state store). +- The `@agentmemory/mcp` standalone MCP server. +- The `@agentmemory/fs-watcher` connector. +- First-party integrations under `integrations/` (`hermes/`, `openclaw/`, `filesystem-watcher/`). +- The Claude Code plugin under `plugin/`. + +Out of scope: + +- Third-party MCP clients consuming agentmemory — report to those projects. +- `iii-sdk` upstream — report to the iii project. +- The marketing site under `website/` unless the issue affects user security (XSS against visitors, credential leak in build output). + +## Supply-chain stance + +agentmemory ships pre-built artifacts in the npm tarball — `dist/` is bundled at publish time, not built from `node_modules` at install time. The package's runtime dependency tree is intentionally small (6 production deps: `@anthropic-ai/sdk`, `@anthropic-ai/claude-agent-sdk`, `@clack/prompts`, `dotenv`, `iii-sdk`, `zod`) plus an optional set guarded behind `optionalDependencies` for embeddings. + +**No lockfile is committed** (#540). The reasoning: + +- The npm tarball ships pre-built `dist/` — fresh installs don't compile from source, so no lockfile is consulted at the user's install step. +- The lockfile only affects contributor-local builds. Pinning it would shift the supply-chain attack surface from "what npm resolves today" to "what was resolved when the lockfile was last regenerated," which is a different tradeoff, not strictly better. +- We use SemVer ranges (`^x.y.z`) on the published deps so security patches reach users without a re-release. + +If you ship agentmemory inside a hardened pipeline that requires reproducible installs, the recommended path is: + +1. `npm install --legacy-peer-deps` against the published tarball in a controlled environment. +2. `npm shrinkwrap` to produce a versioned `npm-shrinkwrap.json` that travels with your deployment. +3. Audit `node_modules/` once at that point and republish internally. + +CI runs `npm install --package-lock-only --legacy-peer-deps --no-audit --no-fund` then `npm ci` against that generated lockfile, so every test job builds against a fully resolved tree. The lockfile is regenerated on each CI run rather than checked in, which keeps the published tarball aligned with whatever SemVer-compatible patch level was current at release time. + +Supply-chain monitoring we already do: + +- Dependabot opens PRs for every minor/patch bump on the production dep list (visible in the open PRs). +- Every PR runs the full test suite on ubuntu-latest + macos-latest, Node 20 + 22, before any merge. +- `optionalDependencies` (`@xenova/transformers`, `onnxruntime-node`, etc.) are guarded by `try { await import("...") } catch` so a missing or compromised optional dep cannot break the core runtime path. + +If you find a malicious package in our dep tree, file via the GHSA flow at the top of this document — that's the fastest path to a fixed release on npm. + +## Past advisories + +See the [`.github/security-advisories/`](./.github/security-advisories) directory for advisory drafts. Published advisories (with assigned GHSA IDs) live at . + +## Safe harbor + +Good-faith research, reported privately, does not get legal heat from the project. Research targeting third-party deployments of agentmemory is not covered — that's between you and the deployer. diff --git a/assets/agents/pi.svg b/assets/agents/pi.svg new file mode 100644 index 0000000..3d40fc0 --- /dev/null +++ b/assets/agents/pi.svg @@ -0,0 +1,20 @@ + + + + + diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..da15c93 Binary files /dev/null and b/assets/banner.png differ diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..272e367 Binary files /dev/null and b/assets/demo.gif differ diff --git a/assets/demo.mp4 b/assets/demo.mp4 new file mode 100644 index 0000000..ecf88cf Binary files /dev/null and b/assets/demo.mp4 differ diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..68b00c1 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/iii-console/states.png b/assets/iii-console/states.png new file mode 100644 index 0000000..7a6f503 Binary files /dev/null and b/assets/iii-console/states.png differ diff --git a/assets/iii-console/traces-waterfall.png b/assets/iii-console/traces-waterfall.png new file mode 100644 index 0000000..976c10c Binary files /dev/null and b/assets/iii-console/traces-waterfall.png differ diff --git a/assets/iii-console/workers.png b/assets/iii-console/workers.png new file mode 100644 index 0000000..2ee093c Binary files /dev/null and b/assets/iii-console/workers.png differ diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..237aafe --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + AGENT + MEMORY + diff --git a/assets/tags/divider.svg b/assets/tags/divider.svg new file mode 100644 index 0000000..20d940f --- /dev/null +++ b/assets/tags/divider.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/tags/light/divider.svg b/assets/tags/light/divider.svg new file mode 100644 index 0000000..45add11 --- /dev/null +++ b/assets/tags/light/divider.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/tags/light/new-v082.svg b/assets/tags/light/new-v082.svg new file mode 100644 index 0000000..e7d745d --- /dev/null +++ b/assets/tags/light/new-v082.svg @@ -0,0 +1,5 @@ + + + NEW + v0.8.2 + \ No newline at end of file diff --git a/assets/tags/light/pill-beta.svg b/assets/tags/light/pill-beta.svg new file mode 100644 index 0000000..9a37b56 --- /dev/null +++ b/assets/tags/light/pill-beta.svg @@ -0,0 +1,5 @@ + + + + BETA + \ No newline at end of file diff --git a/assets/tags/light/pill-hook.svg b/assets/tags/light/pill-hook.svg new file mode 100644 index 0000000..906651a --- /dev/null +++ b/assets/tags/light/pill-hook.svg @@ -0,0 +1,5 @@ + + + + AUTO HOOK + \ No newline at end of file diff --git a/assets/tags/light/pill-mcp.svg b/assets/tags/light/pill-mcp.svg new file mode 100644 index 0000000..3221bc4 --- /dev/null +++ b/assets/tags/light/pill-mcp.svg @@ -0,0 +1,5 @@ + + + + MCP + \ No newline at end of file diff --git a/assets/tags/light/pill-new.svg b/assets/tags/light/pill-new.svg new file mode 100644 index 0000000..a72649f --- /dev/null +++ b/assets/tags/light/pill-new.svg @@ -0,0 +1,5 @@ + + + + NEW + \ No newline at end of file diff --git a/assets/tags/light/pill-plugin.svg b/assets/tags/light/pill-plugin.svg new file mode 100644 index 0000000..f1d4255 --- /dev/null +++ b/assets/tags/light/pill-plugin.svg @@ -0,0 +1,5 @@ + + + + PLUGIN + \ No newline at end of file diff --git a/assets/tags/light/pill-secure.svg b/assets/tags/light/pill-secure.svg new file mode 100644 index 0000000..0efce67 --- /dev/null +++ b/assets/tags/light/pill-secure.svg @@ -0,0 +1,5 @@ + + + + SECURE + \ No newline at end of file diff --git a/assets/tags/light/pill-skill.svg b/assets/tags/light/pill-skill.svg new file mode 100644 index 0000000..d6e0339 --- /dev/null +++ b/assets/tags/light/pill-skill.svg @@ -0,0 +1,5 @@ + + + + SKILL + \ No newline at end of file diff --git a/assets/tags/light/pill-stable.svg b/assets/tags/light/pill-stable.svg new file mode 100644 index 0000000..41baca1 --- /dev/null +++ b/assets/tags/light/pill-stable.svg @@ -0,0 +1,5 @@ + + + + STABLE + \ No newline at end of file diff --git a/assets/tags/light/section-agents.svg b/assets/tags/light/section-agents.svg new file mode 100644 index 0000000..6f8adec --- /dev/null +++ b/assets/tags/light/section-agents.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + WORKS WITH EVERY AGENT + 15 integrations · one memory server + \ No newline at end of file diff --git a/assets/tags/light/section-api.svg b/assets/tags/light/section-api.svg new file mode 100644 index 0000000..1228cb1 --- /dev/null +++ b/assets/tags/light/section-api.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + API + 109 REST endpoints + \ No newline at end of file diff --git a/assets/tags/light/section-architecture.svg b/assets/tags/light/section-architecture.svg new file mode 100644 index 0000000..670bfa3 --- /dev/null +++ b/assets/tags/light/section-architecture.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + ARCHITECTURE + Built on iii-engine's 3 primitives + \ No newline at end of file diff --git a/assets/tags/light/section-benchmarks.svg b/assets/tags/light/section-benchmarks.svg new file mode 100644 index 0000000..5abbfcd --- /dev/null +++ b/assets/tags/light/section-benchmarks.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + BENCHMARKS + Measured on LongMemEval-S (ICLR 2025) + \ No newline at end of file diff --git a/assets/tags/light/section-competitors.svg b/assets/tags/light/section-competitors.svg new file mode 100644 index 0000000..63d1284 --- /dev/null +++ b/assets/tags/light/section-competitors.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + VS COMPETITORS + Mem0 · Letta · Khoj · Hippo · claude-mem + \ No newline at end of file diff --git a/assets/tags/light/section-config.svg b/assets/tags/light/section-config.svg new file mode 100644 index 0000000..59177c4 --- /dev/null +++ b/assets/tags/light/section-config.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + CONFIGURATION + LLM providers, embeddings, and more + \ No newline at end of file diff --git a/assets/tags/light/section-development.svg b/assets/tags/light/section-development.svg new file mode 100644 index 0000000..bbbbbf9 --- /dev/null +++ b/assets/tags/light/section-development.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + DEVELOPMENT + Hot reload, 654 tests, ~1.7s run + \ No newline at end of file diff --git a/assets/tags/light/section-how.svg b/assets/tags/light/section-how.svg new file mode 100644 index 0000000..7470d21 --- /dev/null +++ b/assets/tags/light/section-how.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + HOW IT WORKS + Hook · compress · embed · retrieve + \ No newline at end of file diff --git a/assets/tags/light/section-license.svg b/assets/tags/light/section-license.svg new file mode 100644 index 0000000..6f56ab4 --- /dev/null +++ b/assets/tags/light/section-license.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + LICENSE + Apache-2.0 + \ No newline at end of file diff --git a/assets/tags/light/section-mcp.svg b/assets/tags/light/section-mcp.svg new file mode 100644 index 0000000..c510116 --- /dev/null +++ b/assets/tags/light/section-mcp.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + MCP SERVER + 43 tools · 6 resources · 3 prompts + \ No newline at end of file diff --git a/assets/tags/light/section-quickstart.svg b/assets/tags/light/section-quickstart.svg new file mode 100644 index 0000000..71db9ad --- /dev/null +++ b/assets/tags/light/section-quickstart.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + QUICK START + 30 seconds. No API key needed. + \ No newline at end of file diff --git a/assets/tags/light/section-search.svg b/assets/tags/light/section-search.svg new file mode 100644 index 0000000..5e55e08 --- /dev/null +++ b/assets/tags/light/section-search.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + SEARCH + BM25 + vector + knowledge graph + \ No newline at end of file diff --git a/assets/tags/light/section-viewer.svg b/assets/tags/light/section-viewer.svg new file mode 100644 index 0000000..1a0f567 --- /dev/null +++ b/assets/tags/light/section-viewer.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + REAL-TIME VIEWER + Live observation stream on :3113 + \ No newline at end of file diff --git a/assets/tags/light/section-why.svg b/assets/tags/light/section-why.svg new file mode 100644 index 0000000..ef642ae --- /dev/null +++ b/assets/tags/light/section-why.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + WHY AGENTMEMORY + Goldfish memory costs you $10/day + \ No newline at end of file diff --git a/assets/tags/light/stat-deps.svg b/assets/tags/light/stat-deps.svg new file mode 100644 index 0000000..e32e7b3 --- /dev/null +++ b/assets/tags/light/stat-deps.svg @@ -0,0 +1,5 @@ + + + 0 + EXTERNAL DBS + \ No newline at end of file diff --git a/assets/tags/light/stat-hooks.svg b/assets/tags/light/stat-hooks.svg new file mode 100644 index 0000000..640d59e --- /dev/null +++ b/assets/tags/light/stat-hooks.svg @@ -0,0 +1,5 @@ + + + 12 + AUTO HOOKS + \ No newline at end of file diff --git a/assets/tags/light/stat-recall.svg b/assets/tags/light/stat-recall.svg new file mode 100644 index 0000000..df9e270 --- /dev/null +++ b/assets/tags/light/stat-recall.svg @@ -0,0 +1,5 @@ + + + 95.2% + RETRIEVAL R@5 + \ No newline at end of file diff --git a/assets/tags/light/stat-tests.svg b/assets/tags/light/stat-tests.svg new file mode 100644 index 0000000..5025673 --- /dev/null +++ b/assets/tags/light/stat-tests.svg @@ -0,0 +1,5 @@ + + + 1390+ + TESTS PASSING + diff --git a/assets/tags/light/stat-tokens.svg b/assets/tags/light/stat-tokens.svg new file mode 100644 index 0000000..858abe1 --- /dev/null +++ b/assets/tags/light/stat-tokens.svg @@ -0,0 +1,5 @@ + + + 92% + FEWER TOKENS + \ No newline at end of file diff --git a/assets/tags/light/stat-tools.svg b/assets/tags/light/stat-tools.svg new file mode 100644 index 0000000..e6d5985 --- /dev/null +++ b/assets/tags/light/stat-tools.svg @@ -0,0 +1,5 @@ + + + 53 + MCP TOOLS + diff --git a/assets/tags/new-v082.svg b/assets/tags/new-v082.svg new file mode 100644 index 0000000..f8ec1eb --- /dev/null +++ b/assets/tags/new-v082.svg @@ -0,0 +1,5 @@ + + + NEW + v0.8.2 + \ No newline at end of file diff --git a/assets/tags/pill-beta.svg b/assets/tags/pill-beta.svg new file mode 100644 index 0000000..22b265b --- /dev/null +++ b/assets/tags/pill-beta.svg @@ -0,0 +1,5 @@ + + + + BETA + \ No newline at end of file diff --git a/assets/tags/pill-hook.svg b/assets/tags/pill-hook.svg new file mode 100644 index 0000000..4a9df9b --- /dev/null +++ b/assets/tags/pill-hook.svg @@ -0,0 +1,5 @@ + + + + AUTO HOOK + \ No newline at end of file diff --git a/assets/tags/pill-mcp.svg b/assets/tags/pill-mcp.svg new file mode 100644 index 0000000..56ecebf --- /dev/null +++ b/assets/tags/pill-mcp.svg @@ -0,0 +1,5 @@ + + + + MCP + \ No newline at end of file diff --git a/assets/tags/pill-new.svg b/assets/tags/pill-new.svg new file mode 100644 index 0000000..797b348 --- /dev/null +++ b/assets/tags/pill-new.svg @@ -0,0 +1,5 @@ + + + + NEW + \ No newline at end of file diff --git a/assets/tags/pill-plugin.svg b/assets/tags/pill-plugin.svg new file mode 100644 index 0000000..eafb4ec --- /dev/null +++ b/assets/tags/pill-plugin.svg @@ -0,0 +1,5 @@ + + + + PLUGIN + \ No newline at end of file diff --git a/assets/tags/pill-secure.svg b/assets/tags/pill-secure.svg new file mode 100644 index 0000000..ec87cf2 --- /dev/null +++ b/assets/tags/pill-secure.svg @@ -0,0 +1,5 @@ + + + + SECURE + \ No newline at end of file diff --git a/assets/tags/pill-skill.svg b/assets/tags/pill-skill.svg new file mode 100644 index 0000000..9a525f5 --- /dev/null +++ b/assets/tags/pill-skill.svg @@ -0,0 +1,5 @@ + + + + SKILL + \ No newline at end of file diff --git a/assets/tags/pill-stable.svg b/assets/tags/pill-stable.svg new file mode 100644 index 0000000..2e4c970 --- /dev/null +++ b/assets/tags/pill-stable.svg @@ -0,0 +1,5 @@ + + + + STABLE + \ No newline at end of file diff --git a/assets/tags/section-agents.svg b/assets/tags/section-agents.svg new file mode 100644 index 0000000..386576d --- /dev/null +++ b/assets/tags/section-agents.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + WORKS WITH EVERY AGENT + 15 integrations · one memory server + \ No newline at end of file diff --git a/assets/tags/section-api.svg b/assets/tags/section-api.svg new file mode 100644 index 0000000..331be38 --- /dev/null +++ b/assets/tags/section-api.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + API + 109 REST endpoints + \ No newline at end of file diff --git a/assets/tags/section-architecture.svg b/assets/tags/section-architecture.svg new file mode 100644 index 0000000..d7b60e0 --- /dev/null +++ b/assets/tags/section-architecture.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + ARCHITECTURE + Built on iii-engine's 3 primitives + \ No newline at end of file diff --git a/assets/tags/section-benchmarks.svg b/assets/tags/section-benchmarks.svg new file mode 100644 index 0000000..7de135f --- /dev/null +++ b/assets/tags/section-benchmarks.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + BENCHMARKS + Measured on LongMemEval-S (ICLR 2025) + \ No newline at end of file diff --git a/assets/tags/section-competitors.svg b/assets/tags/section-competitors.svg new file mode 100644 index 0000000..90761e8 --- /dev/null +++ b/assets/tags/section-competitors.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + VS COMPETITORS + Mem0 · Letta · Khoj · Hippo · claude-mem + \ No newline at end of file diff --git a/assets/tags/section-config.svg b/assets/tags/section-config.svg new file mode 100644 index 0000000..27fc49c --- /dev/null +++ b/assets/tags/section-config.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + CONFIGURATION + LLM providers, embeddings, and more + \ No newline at end of file diff --git a/assets/tags/section-development.svg b/assets/tags/section-development.svg new file mode 100644 index 0000000..620be6e --- /dev/null +++ b/assets/tags/section-development.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + DEVELOPMENT + Hot reload, 654 tests, ~1.7s run + \ No newline at end of file diff --git a/assets/tags/section-how.svg b/assets/tags/section-how.svg new file mode 100644 index 0000000..2f34273 --- /dev/null +++ b/assets/tags/section-how.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + HOW IT WORKS + Hook · compress · embed · retrieve + \ No newline at end of file diff --git a/assets/tags/section-license.svg b/assets/tags/section-license.svg new file mode 100644 index 0000000..084f41d --- /dev/null +++ b/assets/tags/section-license.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + LICENSE + Apache-2.0 + \ No newline at end of file diff --git a/assets/tags/section-mcp.svg b/assets/tags/section-mcp.svg new file mode 100644 index 0000000..789ff8f --- /dev/null +++ b/assets/tags/section-mcp.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + MCP SERVER + 43 tools · 6 resources · 3 prompts + \ No newline at end of file diff --git a/assets/tags/section-quickstart.svg b/assets/tags/section-quickstart.svg new file mode 100644 index 0000000..1e81c94 --- /dev/null +++ b/assets/tags/section-quickstart.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + QUICK START + 30 seconds. No API key needed. + \ No newline at end of file diff --git a/assets/tags/section-search.svg b/assets/tags/section-search.svg new file mode 100644 index 0000000..193125a --- /dev/null +++ b/assets/tags/section-search.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + SEARCH + BM25 + vector + knowledge graph + \ No newline at end of file diff --git a/assets/tags/section-viewer.svg b/assets/tags/section-viewer.svg new file mode 100644 index 0000000..cea6dbe --- /dev/null +++ b/assets/tags/section-viewer.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + REAL-TIME VIEWER + Live observation stream on :3113 + \ No newline at end of file diff --git a/assets/tags/section-why.svg b/assets/tags/section-why.svg new file mode 100644 index 0000000..f830311 --- /dev/null +++ b/assets/tags/section-why.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + WHY AGENTMEMORY + Goldfish memory costs you $10/day + \ No newline at end of file diff --git a/assets/tags/stat-deps.svg b/assets/tags/stat-deps.svg new file mode 100644 index 0000000..4db3a1a --- /dev/null +++ b/assets/tags/stat-deps.svg @@ -0,0 +1,5 @@ + + + 0 + EXTERNAL DBS + \ No newline at end of file diff --git a/assets/tags/stat-hooks.svg b/assets/tags/stat-hooks.svg new file mode 100644 index 0000000..93118e1 --- /dev/null +++ b/assets/tags/stat-hooks.svg @@ -0,0 +1,5 @@ + + + 12 + AUTO HOOKS + \ No newline at end of file diff --git a/assets/tags/stat-recall.svg b/assets/tags/stat-recall.svg new file mode 100644 index 0000000..b432497 --- /dev/null +++ b/assets/tags/stat-recall.svg @@ -0,0 +1,5 @@ + + + 95.2% + RETRIEVAL R@5 + \ No newline at end of file diff --git a/assets/tags/stat-tests.svg b/assets/tags/stat-tests.svg new file mode 100644 index 0000000..7dc0f78 --- /dev/null +++ b/assets/tags/stat-tests.svg @@ -0,0 +1,5 @@ + + + 1390+ + TESTS PASSING + diff --git a/assets/tags/stat-tokens.svg b/assets/tags/stat-tokens.svg new file mode 100644 index 0000000..3ba3814 --- /dev/null +++ b/assets/tags/stat-tokens.svg @@ -0,0 +1,5 @@ + + + 92% + FEWER TOKENS + \ No newline at end of file diff --git a/assets/tags/stat-tools.svg b/assets/tags/stat-tools.svg new file mode 100644 index 0000000..2f38d7a --- /dev/null +++ b/assets/tags/stat-tools.svg @@ -0,0 +1,5 @@ + + + 53 + MCP TOOLS + diff --git a/benchmark/COMPARISON.md b/benchmark/COMPARISON.md new file mode 100644 index 0000000..8914c98 --- /dev/null +++ b/benchmark/COMPARISON.md @@ -0,0 +1,165 @@ +# AI Agent Memory: Benchmark Comparison + +How agentmemory compares against other persistent memory solutions for AI coding agents. + +All numbers here come from published benchmarks or public repositories. We link to primary sources wherever possible so you can reproduce. + +--- + +## Retrieval Accuracy (LongMemEval) + +[LongMemEval](https://arxiv.org/abs/2410.10813) (ICLR 2025) measures long-term memory retrieval across ~48 sessions per question on the S variant (500 questions, ~115K tokens each). + +| System | Benchmark | R@5 | Notes | +|---|---|---|---| +| **agentmemory** (BM25 + Vector) | LongMemEval-S | **95.2%** | `all-MiniLM-L6-v2` embeddings, no API key | +| agentmemory (BM25-only) | LongMemEval-S | 86.2% | Fallback when no embedding provider available | +| MemPalace | LongMemEval-S | ~96.6% (self-reported) | Vendor-published number we have not independently reproduced. Vector-only with a larger embedding model and no agent-integration surface (no hooks, no MCP, no multi-agent) | +| oracleagentmemory | LongMemEval | 94.4% (self-reported) | Vendor-published, scored with GPT-5.5 at "xhigh reasoning" and requires an Oracle AI Database. We have not reproduced it. agentmemory's 95.2% uses free local embeddings and no API key | +| Letta / MemGPT | LoCoMo | 83.2% | Different benchmark (LoCoMo, not LongMemEval) | +| Mem0 | LoCoMo | 68.5% | Different benchmark (LoCoMo, not LongMemEval) | + +**⚠️ Apples vs oranges caveat:** only agentmemory's 95.2% is our own measured result, reproducible from the methodology below. Every other number here is the vendor's published claim, on a different benchmark or harness, that we have not independently reproduced: MemPalace and oracleagentmemory report LongMemEval (oracleagentmemory's run used GPT-5.5 at "xhigh reasoning" against an Oracle AI Database), while Letta and Mem0 publish on [LoCoMo](https://snap-stanford.github.io/LoCoMo/). Treat them as ballpark vendor claims, not a head-to-head on identical data. We'd love to run every system on the same dataset; if any maintainer wants to collaborate, open an issue. + +Full agentmemory methodology: [`LONGMEMEVAL.md`](LONGMEMEVAL.md) + +--- + +## Feature Matrix + +| Feature | agentmemory | mem0 | Letta/MemGPT | Khoj | supermemory | MemPalace | oracleagentmemory | Hippo | +|---|---|---|---|---|---|---|---|---| +| **GitHub stars** | Growing | 58K+ | 23K+ | 35K+ | 26K+ | 54K+ | PyPI (Oracle) | Trending | +| **Type** | Memory engine + MCP server | Memory layer API | Full agent runtime | Personal AI | Memory API + app | Benchmark-focused OSS | Memory engine (Oracle DB) | Memory system | +| **Auto-capture via hooks** | ✅ 12 lifecycle hooks | ❌ Manual `add()` | ❌ Agent self-edits | ❌ Manual | ❌ API-side extraction | ❌ Manual | ❌ API extraction | ❌ Manual | +| **Search strategy** | BM25 + Vector + Graph | Vector + Graph | Vector (archival) | Semantic | Vector + RAG | Vector-only (large model) | Vector + semantic | Decay-weighted | +| **Multi-agent coordination** | ✅ Leases + signals + mesh | ❌ | Runtime-internal only | ❌ | ❌ | ❌ | Scoped only (user/agent/thread) | Multi-agent shared | +| **Framework lock-in** | None | None | High | Standalone | None (drop-in wrappers) | None | Oracle Database | None | +| **External deps** | None | Qdrant/pgvector | Postgres + vector | Multiple | Managed cloud | Vector store | Oracle AI Database | None | +| **Self-hostable** | ✅ default | Optional | Optional | ✅ | ❌ Cloud-only | ✅ | ✅ (needs Oracle DB) | ✅ | +| **Knowledge graph** | ✅ Entity extraction + BFS | ✅ Mem0g variant | ❌ | Doc links | ❌ | ❌ | ❌ | ❌ | +| **Memory decay** | ✅ Ebbinghaus + tiered | ❌ | ❌ | ❌ | ✅ Auto-forget | ❌ | ❌ | ✅ Half-lives | +| **4-tier consolidation** | ✅ Working → episodic → semantic → procedural | ❌ | OS-inspired tiers | ❌ | ❌ | ❌ | ❌ | Episodic + semantic | +| **Version / supersession** | ✅ Jaccard-based | Passive | ❌ | ❌ | ✅ Auto-resolve | ❌ | ❌ | ❌ | +| **Real-time viewer** | ✅ Port 3113 | Cloud dashboard | Cloud dashboard | Web UI | Cloud dashboard | ❌ | ❌ | ❌ | +| **Privacy filtering** | ✅ Strips secrets pre-store | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Obsidian export** | ✅ Built-in | ❌ | ❌ | Native format | ❌ | ❌ | ❌ | ❌ | +| **Cross-agent** | ✅ MCP + REST | API calls | Within runtime | Standalone | MCP + API | Standalone | Python API | Multi-agent shared | +| **Audit trail** | ✅ All mutations logged | ❌ | Limited | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Language SDKs** | Any (REST + MCP) | Python + TS | Python only | API | Python + TS | Python | Python only | Node | + +--- + +## Token Efficiency + +The main reason to use persistent memory at all: token cost. Here's what one year of heavy agent use looks like across approaches. + +| Approach | Tokens / year | Cost / year | Notes | +|---|---|---|---| +| Paste full history into context | 19.5M+ | Impossible | Exceeds context window after ~200 observations | +| LLM-summarized memory (extraction-based) | ~650K | ~$500 | Lossy — summarization drops detail | +| **agentmemory (API embeddings)** | **~170K** | **~$10** | Token-budgeted, only relevant memories injected | +| **agentmemory (local embeddings)** | **~170K** | **$0** | `all-MiniLM-L6-v2` runs in-process | +| supermemory | Not published | Cloud pricing | Managed API, no local token budget | +| Mem0 | Varies by integration | Varies | Extraction-based, no token budget | + +**agentmemory ships with a built-in token savings calculator.** Run `npx @agentmemory/agentmemory status` after a few sessions and you'll see exactly how many tokens you've saved vs. pasting the full history. + +--- + +## What Each Tool Is Best At + +This isn't a "agentmemory wins everything" page. Different tools solve different problems. + +**Choose agentmemory if you want:** +- Automatic capture with zero manual `add()` calls +- MCP server that works across Claude Code, Cursor, Codex, Gemini CLI, etc. +- Hybrid BM25 + vector + graph search +- Real-time viewer to see what your agent is learning +- Self-hostable with zero external databases +- Privacy filtering on API keys and secrets +- Multi-agent coordination (leases, signals, routines) + +**Choose Mem0 if you want:** +- Framework-agnostic API to bolt onto an existing agent +- Managed cloud option with a dashboard +- Python + TypeScript SDKs for direct integration +- Entity/relationship extraction as the primary abstraction + +**Choose Letta/MemGPT if you want:** +- A full agent runtime, not just memory +- OS-inspired memory tiers (core/archival/recall) +- Agents that self-edit their memory via function calls +- Long-running conversational agents (weeks/months) + +**Choose Khoj if you want:** +- A personal AI second brain, not agent infrastructure +- Document-first search over your files and the web +- Obsidian/Notion/Emacs integrations +- Scheduled automations and research tasks + +**Choose supermemory if you want:** +- A managed memory API with server-side auto-extraction and automatic forgetting +- Drop-in wrappers for major AI frameworks (Vercel AI, LangChain, LangGraph) +- A hosted dashboard with no infrastructure to run yourself +- RAG plus memory served from a single query + +**Choose MemPalace if you want:** +- A simple, free, open-source vector memory store +- To chase its self-reported retrieval benchmark (we have not reproduced it) +- Pure retrieval over agent workflow features +- Note: no auto-capture, no MCP, no multi-agent coordination, so you wire all integration yourself + +**Choose oracleagentmemory if you want:** +- You already run on Oracle AI Database and want memory inside it +- Enterprise Oracle stack with vector search in the same database +- LLM-backed extraction and are fine paying for a frontier model (their benchmark used GPT-5.5) +- Note: Python-only, Oracle Database required, no MCP, no real-time viewer + +**Choose Hippo if you want:** +- Biologically-inspired memory model (decay, consolidation, sleep) +- Multi-agent shared memory as a primary feature +- "Forget by default, earn persistence through use" philosophy + +--- + +## Running Your Own Benchmarks + +We encourage you to measure this yourself rather than trust any README. Here's how: + +```bash +# Clone the repo +git clone https://github.com/rohitg00/agentmemory.git +cd agentmemory && npm install + +# Run LongMemEval-S +npm run bench:longmemeval + +# Run quality benchmark (240 observations, 20 queries) +npm run bench:quality + +# Run scale benchmark +npm run bench:scale + +# Run real embeddings benchmark +npm run bench:real-embeddings +``` + +Results land in `benchmark/results/`. All scripts, datasets, and results are committed for reproducibility. + +--- + +## Corrections Welcome + +If you maintain one of these tools and we got a number wrong, please open an issue or PR. We'd rather have accurate numbers than convenient ones. + +If you want to add your tool to this comparison, open a PR with: +1. A link to your benchmark methodology +2. The metric and dataset you're measuring on +3. A commit hash / version so we can reproduce + +**Sources:** +- Mem0 LoCoMo benchmark: [mem0.ai blog](https://mem0.ai) +- Letta LoCoMo benchmark: [letta.com/blog/benchmarking-ai-agent-memory](https://letta.com/blog/benchmarking-ai-agent-memory) +- LongMemEval paper: [arxiv.org/abs/2410.10813](https://arxiv.org/abs/2410.10813) +- LoCoMo paper: [snap-stanford.github.io/LoCoMo](https://snap-stanford.github.io/LoCoMo/) diff --git a/benchmark/LONGMEMEVAL.md b/benchmark/LONGMEMEVAL.md new file mode 100644 index 0000000..00de938 --- /dev/null +++ b/benchmark/LONGMEMEVAL.md @@ -0,0 +1,79 @@ +# LongMemEval-S Benchmark Results + +[LongMemEval](https://arxiv.org/abs/2410.10813) (ICLR 2025) is an academic benchmark for evaluating long-term memory in chat assistants. It tests 5 core abilities: information extraction, multi-session reasoning, temporal reasoning, knowledge updates, and abstention. + +## Setup + +- **Dataset**: LongMemEval-S (500 questions, ~48 sessions per question, ~115K tokens) +- **Source**: [xiaowu0162/longmemeval-cleaned](https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned) +- **Metric**: `recall_any@K` — does ANY gold session appear in top-K retrieved results? +- **Embedding model**: `all-MiniLM-L6-v2` (384 dimensions, local, no API key) +- **No LLM in the loop**: Pure retrieval evaluation, no answer generation or judge + +## Results + +| System | R@5 | R@10 | R@20 | NDCG@10 | MRR | +|---|---|---|---|---|---| +| **agentmemory BM25+Vector** | **95.2%** | **98.6%** | **99.4%** | **87.9%** | **88.2%** | +| agentmemory BM25-only | 86.2% | 94.6% | 98.6% | 73.0% | 71.5% | +| MemPalace raw (vector-only) | 96.6% | ~97.6% | — | — | — | + +### By Question Type (BM25+Vector) + +| Type | R@5 | R@10 | Count | +|---|---|---|---| +| knowledge-update | 98.7% | 100.0% | 78 | +| multi-session | 97.7% | 100.0% | 133 | +| single-session-assistant | 96.4% | 98.2% | 56 | +| temporal-reasoning | 95.5% | 97.7% | 133 | +| single-session-user | 90.0% | 97.1% | 70 | +| single-session-preference | 83.3% | 96.7% | 30 | + +### By Question Type (BM25-only) + +| Type | R@5 | R@10 | Count | +|---|---|---|---| +| knowledge-update | 92.3% | 98.7% | 78 | +| single-session-user | 91.4% | 95.7% | 70 | +| temporal-reasoning | 88.0% | 94.7% | 133 | +| multi-session | 86.5% | 96.2% | 133 | +| single-session-assistant | 80.4% | 91.1% | 56 | +| single-session-preference | 60.0% | 80.0% | 30 | + +## Analysis + +1. **BM25+Vector (95.2%) nearly matches pure vector search (96.6%)** with only a 1.4pp gap. Both use the same embedding model (all-MiniLM-L6-v2). + +2. **BM25 alone gets 86.2%** — keyword search with Porter stemming and synonym expansion is surprisingly effective on conversational data. + +3. **Adding vectors to BM25 gives +9pp** (86.2% → 95.2%), the largest improvement from any single component. + +4. **Preferences are the hardest category** for both BM25 (60%) and hybrid (83.3%). These require understanding implicit/indirect statements. + +5. **Multi-session and knowledge-update are strongest** (97.7%+ hybrid). The hybrid approach excels when facts are distributed across sessions. + +6. **R@10 reaches 98.6%** — nearly all gold sessions are found within the top 10 results. + +## Important Notes on Methodology + +- These are **retrieval recall** scores, not end-to-end QA accuracy. The official LongMemEval metric is QA accuracy (retrieve + generate answer + GPT-4o judge). +- Systems on the actual LongMemEval QA leaderboard score 60-95% depending on the LLM reader (Oracle GPT-4o gets ~82.4%). +- We do NOT claim these as "LongMemEval scores" — they are retrieval-only evaluations on the LongMemEval-S haystack. +- Each question builds a fresh index from its ~48 sessions, searches with the question text, and checks if gold session IDs appear in results. + +## Reproducibility + +```bash +# Download dataset (264 MB) +pip install huggingface_hub +python3 -c " +from huggingface_hub import hf_hub_download +hf_hub_download(repo_id='xiaowu0162/longmemeval-cleaned', filename='longmemeval_s_cleaned.json', repo_type='dataset', local_dir='benchmark/data') +" + +# Run BM25-only +npx tsx benchmark/longmemeval-bench.ts bm25 + +# Run BM25+Vector hybrid (requires @xenova/transformers) +npx tsx benchmark/longmemeval-bench.ts hybrid +``` diff --git a/benchmark/QUALITY.md b/benchmark/QUALITY.md new file mode 100644 index 0000000..a5b9caf --- /dev/null +++ b/benchmark/QUALITY.md @@ -0,0 +1,75 @@ +# agentmemory v0.6.0 — Search Quality Evaluation (Internal Dataset) + +> For results on the academic LongMemEval-S benchmark (ICLR 2025, 500 questions), see [`LONGMEMEVAL.md`](LONGMEMEVAL.md) — **95.2% R@5, 98.6% R@10**. + +**Date:** 2026-03-18T07:44:43.397Z +**Dataset:** 240 synthetic observations across 30 sessions (internal coding project) +**Queries:** 20 labeled queries with ground-truth relevance +**Metric definitions:** Recall@K (fraction of relevant docs in top K), Precision@K (fraction of top K that are relevant), NDCG@10 (ranking quality), MRR (position of first relevant result) + +## Head-to-Head Comparison + +| System | Recall@5 | Recall@10 | Precision@5 | NDCG@10 | MRR | Latency | Tokens/query | +|--------|----------|-----------|-------------|---------|-----|---------|--------------| +| Built-in (CLAUDE.md / grep) | 37.0% | 55.8% | 78.0% | 80.3% | 82.5% | 0.50ms | 22,610 | +| Built-in (200-line MEMORY.md) | 27.4% | 37.8% | 63.0% | 56.4% | 65.5% | 0.16ms | 7,938 | +| BM25-only | 43.8% | 55.9% | 95.0% | 82.7% | 95.5% | 0.17ms | 3,142 | +| Dual-stream (BM25+Vector) | 42.4% | 58.6% | 90.0% | 84.7% | 95.4% | 0.71ms | 3,142 | +| Triple-stream (BM25+Vector+Graph) | 36.8% | 58.0% | 87.0% | 81.7% | 87.9% | 1.02ms | 3,142 | + +## Why This Matters + +**Recall improvement:** agentmemory triple-stream finds 58.0% of relevant memories at K=10 vs 55.8% for keyword grep (+4%) +**Token savings:** agentmemory returns only the top 10 results (3,142 tokens) vs loading everything into context (22,610 tokens) — 86% reduction +**200-line cap:** Claude Code's MEMORY.md is capped at 200 lines. With 240 observations, 37.8% recall at K=10 — memories from later sessions are simply invisible. + +## Per-Query Breakdown (Triple-Stream) + +| Query | Category | Recall@10 | NDCG@10 | MRR | Relevant | Latency | +|-------|----------|-----------|---------|-----|----------|---------| +| How did we set up authentication? | semantic | 50.0% | 100.0% | 100.0% | 20 | 1.7ms | +| JWT token validation middleware | exact | 50.0% | 64.9% | 100.0% | 10 | 1.2ms | +| PostgreSQL connection issues | semantic | 33.3% | 100.0% | 100.0% | 30 | 1.0ms | +| Playwright test configuration | exact | 100.0% | 100.0% | 100.0% | 10 | 1.1ms | +| Why did the production deployment fail? | cross-session | 33.3% | 100.0% | 100.0% | 30 | 0.8ms | +| rate limiting implementation | exact | 80.0% | 64.1% | 33.3% | 10 | 0.7ms | +| What security measures did we add? | semantic | 33.3% | 100.0% | 100.0% | 30 | 0.7ms | +| database performance optimization | semantic | 0.0% | 0.0% | 7.1% | 25 | 0.8ms | +| Kubernetes pod crash debugging | entity | 100.0% | 96.7% | 100.0% | 5 | 1.2ms | +| Docker containerization setup | entity | 100.0% | 100.0% | 100.0% | 10 | 0.9ms | +| How does caching work in the app? | semantic | 25.0% | 64.9% | 100.0% | 20 | 0.8ms | +| test infrastructure and factories | exact | 50.0% | 64.9% | 100.0% | 10 | 0.7ms | +| What happened with the OAuth callback error? | cross-session | 100.0% | 54.1% | 16.7% | 5 | 1.1ms | +| monitoring and observability setup | semantic | 66.7% | 100.0% | 100.0% | 15 | 0.8ms | +| Prisma ORM configuration | entity | 25.7% | 93.6% | 100.0% | 35 | 1.8ms | +| CI/CD pipeline configuration | exact | 20.0% | 64.9% | 100.0% | 25 | 1.0ms | +| memory leak debugging | cross-session | 100.0% | 100.0% | 100.0% | 5 | 0.7ms | +| API design decisions | semantic | 25.0% | 64.9% | 100.0% | 20 | 1.4ms | +| zod validation schemas | entity | 66.7% | 100.0% | 100.0% | 15 | 0.7ms | +| infrastructure as code Terraform | entity | 100.0% | 100.0% | 100.0% | 5 | 1.5ms | + +## By Query Category + +| Category | Avg Recall@10 | Avg NDCG@10 | Avg MRR | Queries | +|----------|---------------|-------------|---------|---------| +| exact | 60.0% | 71.8% | 86.7% | 5 | +| semantic | 33.3% | 75.7% | 86.7% | 7 | +| cross-session | 77.8% | 84.7% | 72.2% | 3 | +| entity | 78.5% | 98.1% | 100.0% | 5 | + +## Context Window Analysis + +The fundamental problem with built-in agent memory: + +| Observations | MEMORY.md tokens | agentmemory tokens (top 10) | Savings | MEMORY.md reachable | +|-------------|-----------------|---------------------------|---------|-------------------| +| 240 | 12,000 | 3,142 | 74% | 83% | +| 500 | 25,000 | 3,142 | 87% | 40% | +| 1,000 | 50,000 | 3,142 | 94% | 20% | +| 5,000 | 250,000 | 3,142 | 99% | 4% | + +At 240 observations (our dataset), MEMORY.md already hits its 200-line cap and loses access to the most recent 40 observations. At 1,000 observations, 80% of memories are invisible. agentmemory always searches the full corpus. + +--- + +*100 evaluations across 5 systems. Ground-truth labels assigned by concept matching against observation metadata.* \ No newline at end of file diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..23d2285 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,100 @@ +# benchmark/ + +Two kinds of numbers live in this directory: + +1. **Quality / retrieval** — `longmemeval-bench.ts`, `quality-eval.ts`, + `real-embeddings-eval.ts`, `scale-eval.ts`. Recall, precision, token + savings. Documented in `LONGMEMEVAL.md`, `QUALITY.md`, + `REAL-EMBEDDINGS.md`, `SCALE.md`. + +2. **Load shape** — `load-100k.ts`. p50 / p90 / p99 latency and + throughput against a running daemon. This is the file you want when + somebody asks "what's p99 at 100k memories under concurrency 100?". + +## load-100k.ts + +Hand-rolled, dependency-free load harness. Issues real HTTP against a +local agentmemory daemon at `http://localhost:3111`, records per-request +latency with `performance.now()`, and writes a JSON report per run. + +### What it measures + +For each cell in the matrix `(N, concurrency, endpoint)` it records: + +- `p50_ms`, `p90_ms`, `p99_ms` — nearest-rank percentiles. +- `min_ms`, `max_ms`, `ops`, `errors`. +- `throughput_per_sec` — wall-clock ops / sec for that cell. + +Default matrix: + +- `N` ∈ {1000, 10000, 100000} — number of memories seeded before the + cell runs. +- `C` ∈ {1, 10, 100} — concurrent in-flight requests during the cell. +- Endpoints under test: + - `POST /agentmemory/remember` + - `POST /agentmemory/smart-search` + - `GET /agentmemory/memories?latest=true` + +Each cell issues `BENCH_OPS=200` requests by default — enough samples +for stable p99 without dragging a 100k-seed run past tens of minutes. + +### Why p99 is the number that matters + +p50 tells you the median request feels fast. p90 tells you the bulk of +requests feel fast. **p99 tells you the request your tail user hits when +they really need it feels fast.** Capacity planning lives here — if you +want to size a fleet, scale your daemon, or set an SLO, p99 is the +number to plan against. p50 will lie to you. + +### Running it + +```bash +# 1. Start the daemon however you normally do (npx, Docker, etc.) +npx @agentmemory/agentmemory + +# 2. From the repo root, in another shell: +npm run bench:load +``` + +To override the matrix: + +```bash +BENCH_N=1000 BENCH_C=1,10 BENCH_OPS=100 npm run bench:load +``` + +To have the harness spawn a daemon for the run (after `npm run build`): + +```bash +AGENTMEMORY_BENCH_AUTOSTART=1 npm run bench:load +``` + +Other env knobs (see the file header for the canonical list): + +- `AGENTMEMORY_URL` — base URL of the daemon (default + `http://localhost:3111`). +- `BENCH_SEED` — seed for the `mulberry32` content RNG. Same seed + + same daemon build = byte-identical seed corpus. +- `BENCH_OUT_DIR` — where the JSON report lands (default + `benchmark/results/`). + +### Where results land + +`benchmark/results/load-100k-.json`. The harness +`mkdir -p`s the directory. The file has a `schema_version: 1` field so +future format changes don't silently break consumers. + +### Content generation is seedable + +Synthetic memory content is built from a small noun / verb / concept +vocabulary fed by a `mulberry32(BENCH_SEED)` PRNG. Same seed + same +build = same corpus. The point isn't "realistic" content (there isn't +one realistic content); the point is **reproducibility** — re-running +the harness against the same git sha should give the same content +mixture going in, so latency variance comes from the daemon and not +from JSON payload jitter. + +### Publishing numbers per release + +The release flow appends a `## Performance` section to `CHANGELOG.md` +referencing the JSON in `benchmark/results/` for that release's git +sha. p99 is the headline number; the JSON is the receipt. diff --git a/benchmark/REAL-EMBEDDINGS.md b/benchmark/REAL-EMBEDDINGS.md new file mode 100644 index 0000000..95c5863 --- /dev/null +++ b/benchmark/REAL-EMBEDDINGS.md @@ -0,0 +1,67 @@ +# agentmemory v0.6.0 — Real Embeddings Quality Evaluation + +**Date:** 2026-03-18T07:38:21.450Z +**Platform:** darwin arm64, Node v20.20.0 +**Dataset:** 240 observations, 30 sessions, 20 labeled queries +**Embedding model:** Xenova/all-MiniLM-L6-v2 (384d, local, no API key) + +## Head-to-Head: Real Embeddings vs Keyword Search + +| System | Recall@5 | Recall@10 | Precision@5 | NDCG@10 | MRR | Avg Latency | Tokens/query | +|--------|----------|-----------|-------------|---------|-----|-------------|--------------| +| Built-in (grep all) | 37.0% | 55.8% | 78.0% | 80.3% | 82.5% | 0.44ms | 19,462 | +| BM25-only (stemmed+synonyms) | 43.8% | 55.9% | 95.0% | 82.7% | 95.5% | 0.26ms | 1,571 | +| Dual-stream (BM25+Xenova) | 43.8% | 64.1% | 98.0% | 94.9% | 100.0% | 2.39ms | 1,571 | +| Triple-stream (BM25+Xenova+Graph) | 43.8% | 64.1% | 98.0% | 94.9% | 100.0% | 2.07ms | 1,571 | + +## Improvement from Real Embeddings + +Adding real vector embeddings to BM25 improves recall@10 by **8.2 percentage points**. +Token savings vs loading everything: **92%** (1,571 vs 19,462 tokens). + +## Per-Query: Where Real Embeddings Win + +Queries where dual-stream (real embeddings) outperforms BM25-only: + +| Query | Category | BM25 Recall@10 | +Vector Recall@10 | Delta | +|-------|----------|---------------|-------------------|-------| +| How did we set up authentication? | semantic | 25.0% | 45.0% | +20.0pp ** | +| Playwright test configuration | exact | 50.0% | 90.0% | +40.0pp ** | +| database performance optimization | semantic | 0.0% | 40.0% | +40.0pp ** | +| test infrastructure and factories | exact | 50.0% | 80.0% | +30.0pp ** | +| Prisma ORM configuration | entity | 14.3% | 28.6% | +14.3pp ** | +| CI/CD pipeline configuration | exact | 20.0% | 40.0% | +20.0pp ** | + +## By Category Comparison + +| Category | Built-in grep | BM25 (stemmed) | +Real Vectors | +Graph | +|----------|--------------|----------------|--------------|--------| +| exact | 48.0% | 54.0% | 72.0% | 72.0% | +| semantic | 35.5% | 33.3% | 41.9% | 41.9% | +| cross-session | 77.8% | 77.8% | 77.8% | 77.8% | +| entity | 79.0% | 76.2% | 79.0% | 79.0% | + +## Embedding Performance + +| System | Embedding Time | Model | Dimensions | +|--------|---------------|-------|------------| +| Dual-stream (BM25+Xenova) | 3.1s | Xenova/all-MiniLM-L6-v2 | 384 | +| Triple-stream (BM25+Xenova+Graph) | 2.9s | Xenova/all-MiniLM-L6-v2 | 384 | + +Embedding is a one-time cost at ingestion. Search is sub-millisecond after indexing. + +## Key Findings + +1. **Semantic queries improve most**: 8.6pp recall@10 gain from real embeddings +2. **"database performance optimization"** — the hardest query — goes from BM25 0.0% to vector-augmented 40.0% +3. **Entity/exact queries** are already well-served by BM25+stemming — vectors add marginal value +4. **Local embeddings (Xenova)** run without API keys — zero cost, zero latency concerns + +## Recommendation + +Enable local embeddings by default (`EMBEDDING_PROVIDER=local` or install `@xenova/transformers`). +This gives agentmemory genuine semantic search that built-in agent memories cannot match — +understanding that "database performance optimization" relates to "N+1 query fix" and "eager loading". + +--- +*All measurements use Xenova/all-MiniLM-L6-v2 local embeddings (384 dimensions, no API calls).* \ No newline at end of file diff --git a/benchmark/SCALE.md b/benchmark/SCALE.md new file mode 100644 index 0000000..ae0762d --- /dev/null +++ b/benchmark/SCALE.md @@ -0,0 +1,110 @@ +# agentmemory v0.6.0 — Scale & Cross-Session Evaluation + +**Date:** 2026-03-18T07:45:03.529Z +**Platform:** darwin arm64, Node v20.20.0 + +## 1. Scale: agentmemory vs Built-in Memory + +Every built-in agent memory (CLAUDE.md, .cursorrules, Cline's memory-bank) loads ALL memory into context every session. agentmemory searches and returns only relevant results. + +| Observations | Sessions | Index Build | BM25 Search | Hybrid Search | Heap | Context Tokens (built-in) | Context Tokens (agentmemory) | Savings | Built-in Unreachable | +|-------------|----------|------------|-------------|---------------|------|--------------------------|-----------------------------|---------|--------------------| +| 240 | 30 | 177ms | 0.112ms | 0.63ms | 9MB | 10,504 | 1,924 | 82% | 17% | +| 1,000 | 125 | 155ms | 0.317ms | 1.709ms | 6MB | 43,834 | 1,969 | 96% | 80% | +| 5,000 | 625 | 810ms | 1.496ms | 8.58ms | 25MB | 220,335 | 1,972 | 99% | 96% | +| 10,000 | 1250 | 1657ms | 3.195ms | 17.49ms | 1MB | 440,973 | 1,974 | 100% | 98% | +| 50,000 | 6250 | 9182ms | 22.827ms | 108.722ms | 316MB | 2,216,173 | 1,981 | 100% | 100% | + +### What the numbers mean + +**Context Tokens (built-in):** How many tokens Claude Code/Cursor/Cline would consume loading ALL memory into the context window. At 5,000 observations, this is ~250K tokens — exceeding most context windows entirely. + +**Context Tokens (agentmemory):** How many tokens the top-10 search results consume. Stays constant regardless of corpus size. + +**Built-in Unreachable:** Percentage of memories that built-in systems CANNOT access because they exceed the 200-line MEMORY.md cap or context window limits. At 1,000 observations, 80% of your project history is invisible. + +### Storage Costs + +| Observations | BM25 Index | Vector Index (d=384) | Total Storage | +|-------------|-----------|---------------------|---------------| +| 240 | 395 KB | 494 KB | 0.9 MB | +| 1,000 | 1,599 KB | 2,060 KB | 3.6 MB | +| 5,000 | 8,006 KB | 10,298 KB | 17.9 MB | +| 10,000 | 16,005 KB | 20,596 KB | 35.7 MB | +| 50,000 | 80,126 KB | 102,979 KB | 178.8 MB | + +## 2. Cross-Session Retrieval + +Can the system find relevant information from past sessions? This is impossible for built-in memory once observations exceed the line/context cap. + +| Query | Target Session | Gap | BM25 Found | BM25 Rank | Hybrid Found | Hybrid Rank | Built-in Visible | +|-------|---------------|-----|-----------|-----------|-------------|-------------|-----------------| +| How did we set up OAuth providers? | ses_005-009 | 24 | Yes | #1 | Yes | #1 | Yes | +| What was the N+1 query fix? | ses_010-014 | 18 | Yes | #1 | Yes | #2 | Yes | +| PostgreSQL full-text search setup | ses_010-014 | 17 | Yes | #1 | Yes | #1 | Yes | +| bcrypt password hashing configuration | ses_005-009 | 20 | Yes | #1 | Yes | #1 | Yes | +| Vitest unit testing setup | ses_020-024 | 9 | Yes | #1 | Yes | #1 | Yes | +| webhook retry exponential backoff | ses_015-019 | 14 | Yes | #1 | Yes | #1 | Yes | +| ESLint flat config migration | ses_000-004 | 29 | Yes | #1 | Yes | #1 | Yes | +| Kubernetes HPA autoscaling configuration | ses_025-029 | 4 | Yes | #1 | Yes | #1 | No | +| Prisma database seed script | ses_010-014 | 16 | Yes | #1 | Yes | #1 | Yes | +| API cursor-based pagination | ses_015-019 | 14 | Yes | #1 | Yes | #1 | Yes | +| CSRF protection double-submit cookie | ses_005-009 | 24 | Yes | #1 | Yes | #1 | Yes | +| blue-green deployment rollback | ses_025-029 | 4 | Yes | #1 | Yes | #1 | No | + +**Summary:** agentmemory BM25 found 12/12 cross-session queries. Hybrid found 12/12. Built-in memory (200-line cap) could only reach 10/12. + +## 3. The Context Window Problem + +``` +Agent context window: ~200K tokens +System prompt + tools: ~20K tokens +User conversation: ~30K tokens +Available for memory: ~150K tokens + +At 50 tokens/observation: + 200 observations = 10,000 tokens (fits, but 200-line cap hits first) + 1,000 observations = 50,000 tokens (33% of available budget) + 5,000 observations = 250,000 tokens (EXCEEDS total context window) + +agentmemory top-10 results: + Any corpus size = ~1,924 tokens (0.3% of budget) +``` + +## 4. What Built-in Memory Cannot Do + +| Capability | Built-in (CLAUDE.md) | agentmemory | +|-----------|---------------------|-------------| +| Semantic search | No (keyword grep only) | BM25 + vector + graph | +| Scale beyond 200 lines | No (hard cap) | Unlimited | +| Cross-session recall | Only if in 200-line window | Full corpus search | +| Cross-agent sharing | No (per-agent files) | MCP + REST API | +| Multi-agent coordination | No | Leases, signals, actions | +| Temporal queries | No | Point-in-time graph | +| Memory lifecycle | No (manual pruning) | Ebbinghaus decay + eviction | +| Knowledge graph | No | Entity extraction + traversal | +| Query expansion | No | LLM-generated reformulations | +| Retention scoring | No | Time-frequency decay model | +| Real-time dashboard | No (read files manually) | Viewer on :3113 | +| Concurrent access | No (file lock) | Keyed mutex + KV store | + +## 5. When to Use What + +**Use built-in memory (CLAUDE.md) when:** +- You have < 200 items to remember +- Single agent, single project +- Preferences and quick facts only +- Zero setup is the priority + +**Use agentmemory when:** +- Project history exceeds 200 observations +- You need to recall specific incidents from weeks ago +- Multiple agents work on the same codebase +- You want semantic search ("how does auth work?") not just keyword matching +- You need to track memory quality, decay, and lifecycle +- You want a shared memory layer across Claude Code, Cursor, Windsurf, etc. + +Built-in memory is your sticky notes. agentmemory is the searchable database behind them. + +--- +*Scale tests: 5 corpus sizes. Cross-session tests: 12 queries targeting specific past sessions.* \ No newline at end of file diff --git a/benchmark/dataset.ts b/benchmark/dataset.ts new file mode 100644 index 0000000..a39e87b --- /dev/null +++ b/benchmark/dataset.ts @@ -0,0 +1,293 @@ +import type { CompressedObservation } from "../src/types.js"; + +export interface LabeledQuery { + query: string; + relevantObsIds: string[]; + description: string; + category: "exact" | "semantic" | "temporal" | "cross-session" | "entity"; +} + +const SESSION_COUNT = 30; +const OBS_PER_SESSION = 8; + +function ts(daysAgo: number): string { + return new Date(Date.now() - daysAgo * 86400000).toISOString(); +} + +const RAW_SESSIONS: Array<{ + sessionRange: [number, number]; + daysAgoRange: [number, number]; + project: string; + observations: Array>; +}> = [ + { + sessionRange: [0, 4], + daysAgoRange: [28, 25], + project: "webapp", + observations: [ + { type: "command_run", title: "Initialize Next.js 15 project", subtitle: "create-next-app", facts: ["Created Next.js 15 app with App Router", "TypeScript template selected", "Tailwind CSS v4 configured"], narrative: "Initialized a new Next.js 15 project using create-next-app with TypeScript and Tailwind CSS. Selected the App Router layout.", concepts: ["nextjs", "typescript", "tailwind", "app-router"], files: ["package.json", "tsconfig.json", "tailwind.config.ts"], importance: 6 }, + { type: "file_edit", title: "Configure ESLint with flat config", subtitle: "eslint.config.mjs", facts: ["Migrated to ESLint flat config format", "Added typescript-eslint plugin", "Configured import sorting rules"], narrative: "Set up ESLint using the new flat config format (eslint.config.mjs). Added typescript-eslint for type-aware linting and configured import sorting with eslint-plugin-import.", concepts: ["eslint", "linting", "code-quality", "typescript"], files: ["eslint.config.mjs", "package.json"], importance: 5 }, + { type: "file_edit", title: "Set up Prettier with Tailwind plugin", subtitle: "Formatting", facts: ["Installed prettier and prettier-plugin-tailwindcss", "Added .prettierrc with semi: false, singleQuote: true", "Configured format-on-save in VS Code settings"], narrative: "Configured Prettier for automatic code formatting. Added the Tailwind CSS class sorting plugin. Set up VS Code to format on save.", concepts: ["prettier", "formatting", "tailwind", "developer-experience"], files: [".prettierrc", ".vscode/settings.json"], importance: 4 }, + { type: "file_edit", title: "Create shared UI component library", subtitle: "Components", facts: ["Created Button, Input, Card, Badge components", "Used cva (class-variance-authority) for variant styling", "Added Radix UI primitives for accessibility"], narrative: "Built a shared component library with Button, Input, Card, and Badge components. Used class-variance-authority (cva) for type-safe variant styling and Radix UI primitives for keyboard navigation and screen reader support.", concepts: ["components", "ui-library", "radix-ui", "cva", "accessibility"], files: ["src/components/ui/button.tsx", "src/components/ui/input.tsx", "src/components/ui/card.tsx"], importance: 7 }, + { type: "file_edit", title: "Add global layout with navigation", subtitle: "Layout", facts: ["Created root layout with metadata", "Added responsive navigation bar", "Implemented mobile hamburger menu"], narrative: "Created the root layout component with SEO metadata, Open Graph tags, and a responsive navigation bar that collapses into a hamburger menu on mobile devices.", concepts: ["layout", "navigation", "responsive-design", "seo"], files: ["src/app/layout.tsx", "src/components/nav.tsx"], importance: 6 }, + { type: "file_edit", title: "Configure path aliases and absolute imports", subtitle: "tsconfig", facts: ["Added @ alias pointing to src/", "Configured baseUrl for absolute imports"], narrative: "Set up TypeScript path aliases so imports can use @/components instead of relative paths. Configured baseUrl in tsconfig.json.", concepts: ["typescript", "path-aliases", "developer-experience"], files: ["tsconfig.json"], importance: 3 }, + { type: "command_run", title: "Add Vitest for unit testing", subtitle: "Testing setup", facts: ["Installed vitest and @testing-library/react", "Created vitest.config.ts with jsdom environment", "Added test script to package.json"], narrative: "Set up Vitest as the unit testing framework with React Testing Library for component tests. Configured jsdom environment for DOM testing.", concepts: ["vitest", "testing", "react-testing-library", "configuration"], files: ["vitest.config.ts", "package.json"], importance: 5 }, + { type: "file_edit", title: "Set up Husky pre-commit hooks", subtitle: "Git hooks", facts: ["Installed husky and lint-staged", "Pre-commit runs ESLint and Prettier", "Added commitlint for conventional commits"], narrative: "Configured Husky git hooks with lint-staged to run ESLint and Prettier on staged files before each commit. Added commitlint to enforce conventional commit message format.", concepts: ["husky", "git-hooks", "lint-staged", "commitlint", "ci"], files: [".husky/pre-commit", ".lintstagedrc", "commitlint.config.js"], importance: 4 }, + ], + }, + { + sessionRange: [5, 9], + daysAgoRange: [24, 20], + project: "webapp", + observations: [ + { type: "file_edit", title: "Implement NextAuth.js v5 authentication", subtitle: "Auth setup", facts: ["Configured NextAuth.js v5 with Auth.js", "Added GitHub and Google OAuth providers", "Set up JWT session strategy with 30-day expiry"], narrative: "Implemented authentication using NextAuth.js v5 (Auth.js). Configured GitHub and Google as OAuth providers. Using JWT-based sessions with 30-day expiry instead of database sessions for simplicity.", concepts: ["nextauth", "authentication", "oauth", "jwt", "github", "google"], files: ["src/auth.ts", "src/app/api/auth/[...nextauth]/route.ts", ".env.local"], importance: 9 }, + { type: "file_edit", title: "Create login and signup pages", subtitle: "Auth UI", facts: ["Built login page with OAuth buttons", "Added email/password form with validation", "Implemented error toast notifications"], narrative: "Created the login page with GitHub and Google OAuth sign-in buttons plus an email/password form. Used react-hook-form with zod validation. Added toast notifications for login errors.", concepts: ["login", "signup", "oauth", "form-validation", "react-hook-form", "zod"], files: ["src/app/login/page.tsx", "src/app/signup/page.tsx"], importance: 7 }, + { type: "file_edit", title: "Add middleware for route protection", subtitle: "Auth middleware", facts: ["Created middleware.ts to protect /dashboard routes", "Redirects unauthenticated users to /login", "Allows public access to /api/webhooks"], narrative: "Added Next.js middleware that checks for valid sessions on protected routes (/dashboard/*). Unauthenticated users are redirected to /login. The /api/webhooks path is excluded from auth checks for third-party integrations.", concepts: ["middleware", "route-protection", "authentication", "security"], files: ["src/middleware.ts"], importance: 8 }, + { type: "file_edit", title: "Implement role-based access control", subtitle: "RBAC", facts: ["Added user roles: admin, editor, viewer", "Created withAuth HOC for role checking", "Stored roles in JWT custom claims"], narrative: "Implemented role-based access control with three roles: admin, editor, and viewer. Created a withAuth higher-order component that checks user roles before rendering protected components. Roles are stored as custom claims in the JWT token.", concepts: ["rbac", "authorization", "roles", "jwt-claims", "security"], files: ["src/lib/auth/rbac.ts", "src/lib/auth/with-auth.tsx"], importance: 8 }, + { type: "file_edit", title: "Add password hashing with bcrypt", subtitle: "Security", facts: ["Using bcrypt with cost factor 12", "Added password strength validation (min 8 chars, mixed case, number)", "Implemented rate limiting on login endpoint (5 attempts per 15 min)"], narrative: "Added bcrypt password hashing with cost factor 12 for the email/password authentication flow. Implemented password strength validation requiring minimum 8 characters with mixed case and numbers. Added rate limiting on the login API endpoint: 5 attempts per 15-minute window per IP.", concepts: ["bcrypt", "password-hashing", "rate-limiting", "security", "validation"], files: ["src/lib/auth/password.ts", "src/app/api/auth/login/route.ts"], importance: 9 }, + { type: "file_edit", title: "Create user profile settings page", subtitle: "User settings", facts: ["Profile page shows avatar, name, email", "Added avatar upload with S3 presigned URLs", "Implemented account deletion flow"], narrative: "Built the user profile settings page showing avatar, name, and email. Added avatar upload using S3 presigned URLs for direct browser-to-S3 uploads. Implemented a full account deletion flow with email confirmation.", concepts: ["user-profile", "settings", "s3", "file-upload", "account-deletion"], files: ["src/app/dashboard/settings/page.tsx", "src/app/api/upload/route.ts"], importance: 6 }, + { type: "command_run", title: "Debug OAuth callback URL mismatch", subtitle: "Auth debugging", facts: ["GitHub OAuth callback failed with redirect_uri_mismatch", "Fixed: NEXTAUTH_URL was set to http:// but app served on https://", "Lesson: always use HTTPS in production OAuth callback URLs"], narrative: "Spent time debugging why GitHub OAuth login failed in production. The error was redirect_uri_mismatch. Root cause: NEXTAUTH_URL environment variable was set to http://localhost:3000 in production instead of the HTTPS production URL. Fixed by updating the environment variable.", concepts: ["oauth-debugging", "github", "callback-url", "environment-variables", "production"], files: [".env.production"], importance: 7 }, + { type: "file_edit", title: "Add CSRF protection to API routes", subtitle: "Security", facts: ["Implemented double-submit cookie pattern", "Added CSRF token generation in layout", "Validated CSRF token on all POST/PUT/DELETE requests"], narrative: "Added CSRF protection using the double-submit cookie pattern. A CSRF token is generated on page load and stored in both a cookie and a hidden form field. All mutating API requests (POST, PUT, DELETE) validate the token.", concepts: ["csrf", "security", "cookies", "api-protection"], files: ["src/lib/csrf.ts", "src/middleware.ts"], importance: 8 }, + ], + }, + { + sessionRange: [10, 14], + daysAgoRange: [19, 15], + project: "webapp", + observations: [ + { type: "file_edit", title: "Set up Prisma ORM with PostgreSQL", subtitle: "Database", facts: ["Initialized Prisma with PostgreSQL provider", "Created User, Post, Comment, Tag models", "Generated migrations with prisma migrate dev"], narrative: "Set up Prisma ORM connecting to a PostgreSQL database. Defined the initial schema with User, Post, Comment, and Tag models including many-to-many relationships between Post and Tag.", concepts: ["prisma", "postgresql", "database", "orm", "schema", "migrations"], files: ["prisma/schema.prisma", "src/lib/db.ts"], importance: 9 }, + { type: "file_edit", title: "Create database seed script", subtitle: "Seeding", facts: ["Created seed.ts with faker-generated data", "Seeds 10 users, 50 posts, 200 comments", "Runs via prisma db seed command"], narrative: "Built a database seed script using faker.js to generate realistic test data. Creates 10 users with posts, comments, and tags. Configured to run automatically on prisma db seed.", concepts: ["database", "seeding", "faker", "test-data", "prisma"], files: ["prisma/seed.ts", "package.json"], importance: 5 }, + { type: "file_edit", title: "Implement server actions for CRUD operations", subtitle: "Data layer", facts: ["Created server actions for post CRUD", "Used Prisma transactions for multi-step operations", "Added revalidatePath after mutations"], narrative: "Implemented Next.js server actions for post create, read, update, and delete operations. Used Prisma transactions for operations that modify multiple tables. Called revalidatePath after mutations to refresh cached data.", concepts: ["server-actions", "crud", "prisma", "transactions", "revalidation", "caching"], files: ["src/app/actions/posts.ts"], importance: 8 }, + { type: "command_run", title: "Fix N+1 query in post listing", subtitle: "Performance", facts: ["Identified N+1 query loading post authors individually", "Fixed with Prisma include for eager loading", "Query count dropped from 52 to 3"], narrative: "Discovered an N+1 query problem on the post listing page — each post was triggering a separate query to load its author. Fixed by using Prisma's include option for eager loading. Total query count dropped from 52 to 3.", concepts: ["n+1", "performance", "prisma", "eager-loading", "query-optimization"], files: ["src/app/actions/posts.ts"], importance: 8 }, + { type: "file_edit", title: "Add full-text search with PostgreSQL tsvector", subtitle: "Search", facts: ["Created tsvector column on posts table", "Built GIN index for fast text search", "Implemented search API with ts_rank scoring"], narrative: "Added full-text search using PostgreSQL's built-in tsvector functionality. Created a generated tsvector column combining title and body, with a GIN index. The search API uses ts_rank for relevance scoring and supports phrase matching.", concepts: ["full-text-search", "postgresql", "tsvector", "gin-index", "search"], files: ["prisma/migrations/20260301_add_search.sql", "src/app/api/search/route.ts"], importance: 7 }, + { type: "file_edit", title: "Set up connection pooling with PgBouncer", subtitle: "Database infra", facts: ["Deployed PgBouncer in transaction pooling mode", "Configured max 25 client connections, 10 server connections", "Added DATABASE_URL_DIRECT for migrations (bypasses pooler)"], narrative: "Deployed PgBouncer as a connection pooler for PostgreSQL. Using transaction pooling mode to maximize connection reuse. Configured separate DATABASE_URL for application use (through pooler) and DATABASE_URL_DIRECT for migrations.", concepts: ["pgbouncer", "connection-pooling", "postgresql", "infrastructure"], files: ["docker-compose.yml", ".env"], importance: 7 }, + { type: "command_run", title: "Debug Prisma migration drift", subtitle: "Database debugging", facts: ["prisma migrate deploy failed with drift detected", "Cause: manual SQL ALTER was run directly on production", "Resolution: ran prisma migrate resolve to mark migration as applied"], narrative: "Production deployment failed because Prisma detected schema drift — someone had run a manual ALTER TABLE directly on the production database. Resolved by using prisma migrate resolve to mark the conflicting migration as already applied.", concepts: ["prisma", "migration-drift", "database", "production", "debugging"], files: ["prisma/schema.prisma"], importance: 7 }, + { type: "file_edit", title: "Add Redis caching layer for expensive queries", subtitle: "Caching", facts: ["Used ioredis with 60-second TTL for post listings", "Implemented cache-aside pattern", "Added cache invalidation on post mutations"], narrative: "Added a Redis caching layer for expensive database queries. Post listings are cached for 60 seconds using a cache-aside pattern. Cache entries are invalidated when posts are created, updated, or deleted.", concepts: ["redis", "caching", "cache-aside", "ioredis", "performance"], files: ["src/lib/cache.ts", "src/app/actions/posts.ts"], importance: 7 }, + ], + }, + { + sessionRange: [15, 19], + daysAgoRange: [14, 10], + project: "webapp", + observations: [ + { type: "file_edit", title: "Build REST API with input validation", subtitle: "API", facts: ["Created /api/v1/posts, /api/v1/users endpoints", "Used zod for request body validation", "Added consistent error response format with error codes"], narrative: "Built a versioned REST API under /api/v1/ with endpoints for posts and users. All request bodies are validated with zod schemas. Errors follow a consistent format with error codes, messages, and field-level details.", concepts: ["rest-api", "zod", "validation", "error-handling", "api-design"], files: ["src/app/api/v1/posts/route.ts", "src/app/api/v1/users/route.ts", "src/lib/api/errors.ts"], importance: 8 }, + { type: "file_edit", title: "Implement cursor-based pagination", subtitle: "API pagination", facts: ["Replaced offset pagination with cursor-based approach", "Uses Prisma cursor with opaque base64-encoded cursors", "Returns hasNextPage and endCursor in response"], narrative: "Switched from offset-based to cursor-based pagination for the post listing API. Cursors are base64-encoded Prisma record IDs. Response includes hasNextPage boolean and endCursor for the client to request the next page.", concepts: ["pagination", "cursor-based", "prisma", "api-design", "performance"], files: ["src/app/api/v1/posts/route.ts", "src/lib/api/pagination.ts"], importance: 7 }, + { type: "file_edit", title: "Add API rate limiting with Upstash Redis", subtitle: "Rate limiting", facts: ["Used @upstash/ratelimit with sliding window algorithm", "10 requests per 10 seconds per API key", "Returns X-RateLimit-Remaining header"], narrative: "Implemented API rate limiting using Upstash Redis with a sliding window algorithm. Each API key is limited to 10 requests per 10-second window. Rate limit status is communicated via standard X-RateLimit-* headers.", concepts: ["rate-limiting", "upstash", "redis", "api-security", "sliding-window"], files: ["src/middleware.ts", "src/lib/rate-limit.ts"], importance: 8 }, + { type: "file_edit", title: "Create webhook system for external integrations", subtitle: "Webhooks", facts: ["Built webhook registration and delivery system", "Events: post.created, post.updated, user.signup", "Implemented retry with exponential backoff (max 3 retries)"], narrative: "Created a webhook system allowing external services to subscribe to events. Supports post.created, post.updated, and user.signup events. Webhook deliveries use exponential backoff with up to 3 retries on failure.", concepts: ["webhooks", "events", "integrations", "retry", "exponential-backoff"], files: ["src/lib/webhooks.ts", "src/app/api/v1/webhooks/route.ts"], importance: 7 }, + { type: "file_edit", title: "Add OpenAPI specification with Swagger UI", subtitle: "API docs", facts: ["Generated OpenAPI 3.1 spec from zod schemas", "Added Swagger UI at /api/docs", "Included request/response examples"], narrative: "Generated an OpenAPI 3.1 specification from the existing zod validation schemas. Added Swagger UI accessible at /api/docs for interactive API documentation with request/response examples.", concepts: ["openapi", "swagger", "api-documentation", "zod"], files: ["src/app/api/docs/route.ts", "src/lib/openapi.ts"], importance: 5 }, + { type: "command_run", title: "Debug 504 gateway timeout on large queries", subtitle: "Performance debugging", facts: ["Large post queries timing out after 30 seconds on Vercel", "Root cause: missing database index on posts.authorId", "Added composite index (authorId, createdAt DESC), query dropped to 50ms"], narrative: "Investigated 504 Gateway Timeout errors on the post listing endpoint in production (Vercel). Found that large queries filtering by author were doing a full table scan. Added a composite index on (authorId, createdAt DESC) which reduced query time from 30+ seconds to 50ms.", concepts: ["performance", "timeout", "database-index", "postgresql", "vercel", "debugging"], files: ["prisma/migrations/20260310_add_author_index.sql"], importance: 9 }, + { type: "file_edit", title: "Implement API versioning strategy", subtitle: "API design", facts: ["URL-based versioning: /api/v1/, /api/v2/", "v1 deprecated with Sunset header", "Migration guide in API docs"], narrative: "Established an API versioning strategy using URL-based versioning (/api/v1/, /api/v2/). The v1 API returns a Sunset header indicating its deprecation date. Added a migration guide to the API documentation.", concepts: ["api-versioning", "deprecation", "sunset-header", "backward-compatibility"], files: ["src/app/api/v2/posts/route.ts", "src/lib/api/versioning.ts"], importance: 6 }, + { type: "file_edit", title: "Add request logging with structured JSON", subtitle: "Observability", facts: ["Used pino for structured JSON logging", "Logs request method, path, status, duration, user ID", "Configured log levels per environment"], narrative: "Added structured JSON request logging using pino. Each request logs method, path, response status, duration in milliseconds, and authenticated user ID. Log levels are configured per environment (debug in dev, info in production).", concepts: ["logging", "pino", "observability", "structured-logging", "monitoring"], files: ["src/lib/logger.ts", "src/middleware.ts"], importance: 6 }, + ], + }, + { + sessionRange: [20, 24], + daysAgoRange: [9, 5], + project: "webapp", + observations: [ + { type: "file_edit", title: "Write unit tests for auth module", subtitle: "Testing", facts: ["25 test cases covering login, signup, role checking", "Mocked Prisma client with vitest", "Achieved 92% coverage on auth module"], narrative: "Wrote comprehensive unit tests for the authentication module. 25 test cases covering login flow, signup validation, role-based access checks, and password hashing. Mocked the Prisma client using vitest's vi.mock. Achieved 92% code coverage.", concepts: ["unit-testing", "vitest", "mocking", "authentication", "coverage"], files: ["tests/unit/auth.test.ts", "tests/unit/rbac.test.ts"], importance: 7 }, + { type: "file_edit", title: "Add E2E tests with Playwright", subtitle: "E2E testing", facts: ["Configured Playwright with Chrome and Firefox", "Tests: login flow, post CRUD, search, pagination", "Set up test database with Docker for isolation"], narrative: "Set up Playwright for end-to-end testing with Chrome and Firefox browsers. Created E2E tests for the complete login flow, post CRUD operations, search functionality, and pagination. Each test run gets a fresh database via Docker containers.", concepts: ["playwright", "e2e-testing", "docker", "test-isolation", "browser-testing"], files: ["playwright.config.ts", "tests/e2e/auth.spec.ts", "tests/e2e/posts.spec.ts", "docker-compose.test.yml"], importance: 8 }, + { type: "command_run", title: "Fix flaky Playwright test on CI", subtitle: "CI debugging", facts: ["Test passed locally but failed in GitHub Actions", "Root cause: missing waitForNavigation after form submit", "Fixed by using page.waitForURL instead of waitForNavigation"], narrative: "Debugged a flaky Playwright test that passed locally but failed intermittently in GitHub Actions CI. The issue was a race condition after form submission — the test was checking the URL before navigation completed. Fixed by replacing the deprecated waitForNavigation with page.waitForURL.", concepts: ["playwright", "flaky-test", "ci", "github-actions", "debugging", "race-condition"], files: ["tests/e2e/auth.spec.ts"], importance: 6 }, + { type: "file_edit", title: "Add API integration tests with supertest", subtitle: "API testing", facts: ["30 test cases for REST API endpoints", "Tests validation, auth, error responses, pagination", "Uses test database with transaction rollback"], narrative: "Created API integration tests using supertest. 30 test cases covering request validation, authentication requirements, error response formats, and cursor-based pagination. Each test runs in a database transaction that rolls back after completion.", concepts: ["integration-testing", "supertest", "api-testing", "transactions", "test-isolation"], files: ["tests/integration/api.test.ts"], importance: 7 }, + { type: "file_edit", title: "Set up test coverage reporting with codecov", subtitle: "Coverage", facts: ["Configured vitest coverage with v8 provider", "Minimum coverage thresholds: 80% branches, 85% lines", "Upload to Codecov in CI pipeline"], narrative: "Configured vitest code coverage using the v8 provider. Set minimum coverage thresholds at 80% for branches and 85% for lines. Coverage reports are uploaded to Codecov as part of the GitHub Actions CI pipeline.", concepts: ["code-coverage", "codecov", "vitest", "ci", "quality-gates"], files: ["vitest.config.ts", ".github/workflows/ci.yml"], importance: 5 }, + { type: "file_edit", title: "Create test fixtures and factories", subtitle: "Test infrastructure", facts: ["Built factory functions for User, Post, Comment, Tag", "Uses faker for realistic data generation", "Supports partial overrides for specific test scenarios"], narrative: "Created test factory functions for all main models (User, Post, Comment, Tag). Factories use faker.js for realistic data and support partial overrides so individual tests can customize specific fields.", concepts: ["test-factories", "faker", "testing-infrastructure", "fixtures"], files: ["tests/fixtures/factories.ts"], importance: 5 }, + { type: "command_run", title: "Debug memory leak in test suite", subtitle: "Test debugging", facts: ["Tests consuming 2GB+ RAM after 100+ test files", "Root cause: Prisma client not disconnected in afterAll", "Fixed by adding global teardown that calls prisma.$disconnect()"], narrative: "Investigated why the test suite was consuming over 2GB of RAM. The Prisma client was creating new connections in each test file but never disconnecting. Fixed by adding a global teardown hook that calls prisma.$disconnect().", concepts: ["memory-leak", "testing", "prisma", "debugging", "resource-management"], files: ["vitest.config.ts", "tests/setup.ts"], importance: 7 }, + { type: "file_edit", title: "Add snapshot testing for API responses", subtitle: "Snapshot tests", facts: ["Added toMatchSnapshot for API response shapes", "Snapshot updates require --update flag", "Catches unintended breaking changes in API responses"], narrative: "Added snapshot testing for API response shapes to catch unintended breaking changes. Response bodies are compared against stored snapshots. Snapshots must be explicitly updated with the --update flag when intentional changes are made.", concepts: ["snapshot-testing", "api-testing", "regression-testing", "vitest"], files: ["tests/integration/api.test.ts", "tests/integration/__snapshots__/"], importance: 4 }, + ], + }, + { + sessionRange: [25, 29], + daysAgoRange: [4, 0], + project: "webapp", + observations: [ + { type: "file_edit", title: "Create multi-stage Dockerfile", subtitle: "Docker", facts: ["Multi-stage build: deps → build → production", "Final image size 180MB (down from 1.2GB)", "Runs as non-root user with UID 1001"], narrative: "Created a multi-stage Dockerfile for the Next.js application. Stage 1 installs dependencies, stage 2 builds the app, stage 3 copies only production artifacts. Final image is 180MB (down from 1.2GB). Application runs as a non-root user for security.", concepts: ["docker", "multi-stage-build", "containerization", "security", "image-optimization"], files: ["Dockerfile", ".dockerignore"], importance: 7 }, + { type: "file_edit", title: "Set up GitHub Actions CI/CD pipeline", subtitle: "CI/CD", facts: ["Matrix build: Node 18 and 20", "Jobs: lint, test, build, deploy", "Auto-deploy to Vercel on main branch push"], narrative: "Created a comprehensive GitHub Actions CI/CD pipeline with matrix builds for Node 18 and 20. Pipeline runs lint, test (with coverage), build, and deploy jobs. Merges to main automatically trigger Vercel deployment.", concepts: ["github-actions", "ci-cd", "deployment", "vercel", "automation"], files: [".github/workflows/ci.yml", ".github/workflows/deploy.yml"], importance: 8 }, + { type: "file_edit", title: "Configure Kubernetes deployment manifests", subtitle: "K8s", facts: ["Created Deployment, Service, Ingress, HPA resources", "HPA: min 2, max 10 replicas, CPU target 70%", "Health checks: liveness on /healthz, readiness on /readyz"], narrative: "Created Kubernetes deployment manifests including Deployment, Service, Ingress, and HorizontalPodAutoscaler. HPA scales between 2 and 10 replicas targeting 70% CPU utilization. Added liveness and readiness probes for health monitoring.", concepts: ["kubernetes", "deployment", "hpa", "autoscaling", "health-checks", "ingress"], files: ["k8s/deployment.yaml", "k8s/service.yaml", "k8s/ingress.yaml", "k8s/hpa.yaml"], importance: 8 }, + { type: "file_edit", title: "Add Terraform for AWS infrastructure", subtitle: "IaC", facts: ["VPC with public/private subnets across 3 AZs", "RDS PostgreSQL with Multi-AZ failover", "ElastiCache Redis cluster with 2 replicas"], narrative: "Created Terraform modules for AWS infrastructure. VPC spans 3 availability zones with public and private subnets. RDS PostgreSQL instance with Multi-AZ failover for high availability. ElastiCache Redis cluster with 2 read replicas.", concepts: ["terraform", "aws", "infrastructure-as-code", "vpc", "rds", "elasticache"], files: ["terraform/main.tf", "terraform/vpc.tf", "terraform/rds.tf", "terraform/redis.tf"], importance: 8 }, + { type: "command_run", title: "Debug Kubernetes pod crash loop", subtitle: "K8s debugging", facts: ["Pods in CrashLoopBackOff status", "Root cause: DATABASE_URL secret not mounted correctly", "Fixed: Secret key name was 'database-url' but env var expected 'DATABASE_URL'"], narrative: "Debugged pods stuck in CrashLoopBackOff. The application was failing to start because the DATABASE_URL environment variable was empty. Root cause: the Kubernetes secret had the key 'database-url' (kebab-case) but the secretKeyRef expected 'DATABASE_URL' (uppercase).", concepts: ["kubernetes", "debugging", "crashloopbackoff", "secrets", "environment-variables"], files: ["k8s/deployment.yaml", "k8s/secrets.yaml"], importance: 8 }, + { type: "file_edit", title: "Set up Datadog monitoring and alerting", subtitle: "Monitoring", facts: ["Deployed Datadog agent as DaemonSet", "Custom metrics: request latency, error rate, DB query time", "Alerts: p99 latency > 500ms, error rate > 1%"], narrative: "Deployed the Datadog monitoring agent as a Kubernetes DaemonSet. Created custom metrics for request latency, error rate, and database query time. Set up alerts that trigger when p99 latency exceeds 500ms or error rate exceeds 1%.", concepts: ["datadog", "monitoring", "alerting", "observability", "kubernetes"], files: ["k8s/datadog-agent.yaml", "src/lib/metrics.ts"], importance: 7 }, + { type: "file_edit", title: "Implement blue-green deployment strategy", subtitle: "Deployment", facts: ["Two identical environments: blue and green", "Health check must pass before traffic switch", "Instant rollback by switching back to previous color"], narrative: "Implemented blue-green deployment strategy. Two identical environments run simultaneously — deploy to the inactive one, run health checks, then switch traffic via Kubernetes service selector update. Rollback is instant by pointing traffic back to the previous color.", concepts: ["blue-green", "deployment-strategy", "zero-downtime", "rollback", "kubernetes"], files: ["k8s/blue-deployment.yaml", "k8s/green-deployment.yaml", "scripts/deploy.sh"], importance: 7 }, + { type: "file_edit", title: "Add Prometheus metrics and Grafana dashboards", subtitle: "Observability", facts: ["Exported custom metrics via /metrics endpoint", "Metrics: http_request_duration, db_query_duration, cache_hit_ratio", "Created Grafana dashboard with request rate, latency, error panels"], narrative: "Added Prometheus metrics export on a /metrics endpoint. Custom metrics include HTTP request duration histogram, database query duration, and cache hit ratio. Created a Grafana dashboard with panels for request rate, latency percentiles, error rate, and cache performance.", concepts: ["prometheus", "grafana", "metrics", "observability", "dashboards"], files: ["src/lib/metrics.ts", "grafana/dashboard.json"], importance: 6 }, + ], + }, +]; + +export function generateDataset(): { + observations: CompressedObservation[]; + queries: LabeledQuery[]; + sessions: Map; +} { + const observations: CompressedObservation[] = []; + const sessions = new Map(); + + for (const group of RAW_SESSIONS) { + const [sStart, sEnd] = group.sessionRange; + const [dStart, dEnd] = group.daysAgoRange; + + for (let s = sStart; s <= sEnd; s++) { + const sessionId = `ses_${s.toString().padStart(3, "0")}`; + const daysAgo = dStart - ((s - sStart) / Math.max(1, sEnd - sStart)) * (dStart - dEnd); + const obsIds: string[] = []; + + const obsPerSession = Math.min(group.observations.length, OBS_PER_SESSION); + for (let o = 0; o < obsPerSession; o++) { + const idx = ((s - sStart) * obsPerSession + o) % group.observations.length; + const raw = group.observations[idx]; + const obsId = `obs_${sessionId}_${o.toString().padStart(2, "0")}`; + const hourOffset = o * 0.5; + + observations.push({ + id: obsId, + sessionId, + timestamp: ts(daysAgo - hourOffset / 24), + ...raw, + }); + obsIds.push(obsId); + } + sessions.set(sessionId, obsIds); + } + } + + const queries: LabeledQuery[] = [ + { + query: "How did we set up authentication?", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["nextauth", "authentication", "oauth", "jwt", "login", "signup"].includes(c))).map(o => o.id), + description: "Should find all auth-related observations across sessions 5-9", + category: "semantic", + }, + { + query: "JWT token validation middleware", + relevantObsIds: observations.filter(o => o.concepts.includes("jwt") || (o.concepts.includes("middleware") && o.concepts.includes("authentication"))).map(o => o.id), + description: "Exact match on JWT middleware setup", + category: "exact", + }, + { + query: "PostgreSQL connection issues", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["postgresql", "pgbouncer", "connection-pooling", "database"].includes(c))).map(o => o.id), + description: "Should find database connection and pooling observations", + category: "semantic", + }, + { + query: "Playwright test configuration", + relevantObsIds: observations.filter(o => o.concepts.includes("playwright") || (o.concepts.includes("e2e-testing"))).map(o => o.id), + description: "E2E testing setup with Playwright", + category: "exact", + }, + { + query: "Why did the production deployment fail?", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["debugging", "production", "crashloopbackoff", "timeout", "migration-drift"].includes(c))).map(o => o.id), + description: "Cross-session: find all production debugging incidents", + category: "cross-session", + }, + { + query: "rate limiting implementation", + relevantObsIds: observations.filter(o => o.concepts.includes("rate-limiting")).map(o => o.id), + description: "Rate limiting across auth and API modules", + category: "exact", + }, + { + query: "What security measures did we add?", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["security", "csrf", "bcrypt", "rate-limiting", "rbac", "password-hashing"].includes(c))).map(o => o.id), + description: "Broad semantic: all security-related work", + category: "semantic", + }, + { + query: "database performance optimization", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["n+1", "query-optimization", "database-index", "performance", "eager-loading", "caching"].includes(c))).map(o => o.id), + description: "Performance optimizations across database and caching", + category: "semantic", + }, + { + query: "Kubernetes pod crash debugging", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["crashloopbackoff", "kubernetes"].includes(c)) && o.concepts.includes("debugging")).map(o => o.id), + description: "Specific K8s debugging incident", + category: "entity", + }, + { + query: "Docker containerization setup", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["docker", "multi-stage-build", "containerization", "dockerfile"].includes(c))).map(o => o.id), + description: "Docker-related observations", + category: "entity", + }, + { + query: "How does caching work in the app?", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["redis", "caching", "cache-aside", "ioredis", "elasticache"].includes(c))).map(o => o.id), + description: "All caching-related observations", + category: "semantic", + }, + { + query: "test infrastructure and factories", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["test-factories", "testing-infrastructure", "fixtures", "mocking"].includes(c))).map(o => o.id), + description: "Test setup infrastructure", + category: "exact", + }, + { + query: "What happened with the OAuth callback error?", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["oauth-debugging", "callback-url"].includes(c))).map(o => o.id), + description: "Specific debugging incident recall", + category: "cross-session", + }, + { + query: "monitoring and observability setup", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["datadog", "prometheus", "grafana", "monitoring", "observability", "alerting", "metrics", "logging", "pino"].includes(c))).map(o => o.id), + description: "All monitoring/observability observations", + category: "semantic", + }, + { + query: "Prisma ORM configuration", + relevantObsIds: observations.filter(o => o.concepts.includes("prisma")).map(o => o.id), + description: "All Prisma-related observations", + category: "entity", + }, + { + query: "CI/CD pipeline configuration", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["ci-cd", "github-actions", "deployment", "ci"].includes(c))).map(o => o.id), + description: "CI/CD related observations", + category: "exact", + }, + { + query: "memory leak debugging", + relevantObsIds: observations.filter(o => o.concepts.includes("memory-leak")).map(o => o.id), + description: "Memory leak incidents (WebSocket handler, test suite)", + category: "cross-session", + }, + { + query: "API design decisions", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["rest-api", "api-design", "api-versioning", "pagination", "openapi", "error-handling"].includes(c))).map(o => o.id), + description: "API design and architecture decisions", + category: "semantic", + }, + { + query: "zod validation schemas", + relevantObsIds: observations.filter(o => o.concepts.includes("zod")).map(o => o.id), + description: "Where zod is used for validation", + category: "entity", + }, + { + query: "infrastructure as code Terraform", + relevantObsIds: observations.filter(o => o.concepts.some(c => ["terraform", "infrastructure-as-code", "aws", "vpc", "rds", "elasticache"].includes(c))).map(o => o.id), + description: "Terraform/IaC observations", + category: "entity", + }, + ]; + + return { observations, queries, sessions }; +} + +export function generateScaleDataset(count: number): CompressedObservation[] { + const base = generateDataset().observations; + const result: CompressedObservation[] = []; + + for (let i = 0; i < count; i++) { + const src = base[i % base.length]; + result.push({ + ...src, + id: `obs_scale_${i.toString().padStart(6, "0")}`, + sessionId: `ses_${Math.floor(i / 8).toString().padStart(4, "0")}`, + timestamp: ts(Math.random() * 90), + title: `${src.title} (iteration ${i})`, + narrative: `${src.narrative} [Scale test variant ${i}, session group ${Math.floor(i / 8)}]`, + }); + } + return result; +} diff --git a/benchmark/lib/percentiles.ts b/benchmark/lib/percentiles.ts new file mode 100644 index 0000000..204feea --- /dev/null +++ b/benchmark/lib/percentiles.ts @@ -0,0 +1,22 @@ +/** + * Nearest-rank percentile over a pre-sorted ascending array of numbers. + * + * No dependencies, no allocation. The caller is responsible for sorting + * the input ascending (`arr.sort((a, b) => a - b)`) — sorting in here + * would hide an O(n log n) cost in what looks like a cheap lookup. + * + * @param sorted Ascending-sorted samples. Empty array returns `NaN`. + * @param p Percentile in [0, 100]. Values outside the range are clamped. + * @returns The sample at the nearest rank, or `NaN` for empty input. + */ +export function pXX(sorted: number[], p: number): number { + const n = sorted.length; + if (n === 0) return NaN; + const clamped = Math.max(0, Math.min(100, p)); + if (clamped === 0) return sorted[0]!; + if (clamped === 100) return sorted[n - 1]!; + // Nearest-rank: rank = ceil(p/100 * n), index = rank - 1. + const rank = Math.ceil((clamped / 100) * n); + const idx = Math.min(n - 1, Math.max(0, rank - 1)); + return sorted[idx]!; +} diff --git a/benchmark/load-100k.ts b/benchmark/load-100k.ts new file mode 100644 index 0000000..676aa64 --- /dev/null +++ b/benchmark/load-100k.ts @@ -0,0 +1,528 @@ +/** + * Load harness — seeds N synthetic memories against a local agentmemory + * daemon, then drives a matrix of (N, concurrency, endpoint) cells and + * records p50 / p90 / p99 latency + throughput per cell. + * + * Spec: GitHub issue #346. + * + * Runs against an already-running daemon at `http://localhost:3111` by + * default. Set `AGENTMEMORY_BENCH_AUTOSTART=1` to spawn one via + * `node dist/cli.js start` for the duration of the run. + * + * Env knobs: + * AGENTMEMORY_BENCH_AUTOSTART "1" to spawn the daemon (default: assume up) + * AGENTMEMORY_URL base URL of the daemon (default: http://localhost:3111) + * BENCH_N comma-separated N sizes (default: 1000,10000,100000) + * BENCH_C comma-separated concurrency levels (default: 1,10,100) + * BENCH_OPS ops per cell during measurement (default: 200) + * BENCH_SEED seed for the mulberry32 RNG (default: 0xC0FFEE) + * BENCH_OUT_DIR results dir (default: benchmark/results) + * + * The harness writes one JSON file per run named + * `load-100k-.json`. The git sha is best-effort — falls + * back to a timestamp when run outside a checkout. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +import { pXX } from "./lib/percentiles.js"; + +/** Seedable PRNG. Mulberry32 — 32-bit state, uniform output in [0, 1). */ +function mulberry32(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const NOUNS = [ + "cache", "queue", "router", "stream", "shard", "lock", "buffer", "worker", + "engine", "trigger", "function", "memory", "index", "graph", "vector", + "session", "observation", "summary", "embedding", "tokenizer", "scheduler", + "consumer", "producer", "channel", "actor", "pipeline", "watcher", "pool", +]; +const VERBS = [ + "flushes", "rotates", "compacts", "rebalances", "drains", "warms", + "expires", "deduplicates", "snapshots", "replays", "promotes", "demotes", + "merges", "splits", "indexes", "scans", "compresses", "uploads", +]; +const CONCEPTS = [ + "throughput", "latency", "backpressure", "consistency", "isolation", + "durability", "idempotency", "fan-out", "cardinality", "skew", + "hot-path", "cold-start", "tail-latency", "saturation", "quiescence", +]; + +function buildContent(rng: () => number, i: number): string { + const n = NOUNS[Math.floor(rng() * NOUNS.length)]!; + const v = VERBS[Math.floor(rng() * VERBS.length)]!; + const c1 = CONCEPTS[Math.floor(rng() * CONCEPTS.length)]!; + const c2 = CONCEPTS[Math.floor(rng() * CONCEPTS.length)]!; + const k = Math.floor(rng() * 9999); + return `seed-${i} the ${n} ${v} ${c1} under ${c2} pressure (k=${k})`; +} + +interface RunConfig { + baseUrl: string; + Ns: number[]; + Cs: number[]; + opsPerCell: number; + seed: number; + outDir: string; + autoStart: boolean; +} + +function parseIntList(raw: string | undefined, fallback: number[]): number[] { + if (!raw) return fallback; + const out = raw + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n) && n > 0); + return out.length > 0 ? out : fallback; +} + +function loadConfig(): RunConfig { + return { + baseUrl: (process.env["AGENTMEMORY_URL"] || "http://localhost:3111").replace( + /\/+$/, + "", + ), + Ns: parseIntList(process.env["BENCH_N"], [1000, 10000, 100000]), + Cs: parseIntList(process.env["BENCH_C"], [1, 10, 100]), + opsPerCell: parseInt(process.env["BENCH_OPS"] || "200", 10) || 200, + seed: parseInt(process.env["BENCH_SEED"] || "12648430", 10) || 12648430, + outDir: + process.env["BENCH_OUT_DIR"] || + resolve(process.cwd(), "benchmark", "results"), + autoStart: process.env["AGENTMEMORY_BENCH_AUTOSTART"] === "1", + }; +} + +async function waitForLivez(baseUrl: string, timeoutMs: number): Promise { + const start = Date.now(); + let lastErr: unknown = null; + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${baseUrl}/agentmemory/livez`, { + signal: AbortSignal.timeout(2000), + }); + if (res.ok) return; + lastErr = new Error(`livez HTTP ${res.status}`); + } catch (err) { + lastErr = err; + } + await new Promise((r) => setTimeout(r, 500)); + } + const reason = + lastErr instanceof Error ? lastErr.message : String(lastErr ?? "unknown"); + throw new Error( + `daemon at ${baseUrl} did not become ready within ${timeoutMs} ms: ${reason}`, + ); +} + +function maybeStartDaemon(): ChildProcess | null { + const candidates = ["dist/cli.mjs", "dist/cli.js"].map((p) => + resolve(process.cwd(), p), + ); + const cliPath = candidates.find((p) => existsSync(p)); + if (!cliPath) { + throw new Error( + `AGENTMEMORY_BENCH_AUTOSTART=1 but neither ${candidates.join(" nor ")} exists — run \`npm run build\` first`, + ); + } + const child = spawn(process.execPath, [cliPath, "start"], { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + child.stdout?.on("data", () => { + /* drain */ + }); + child.stderr?.on("data", () => { + /* drain */ + }); + return child; +} + +function shortGitSha(): string { + try { + const sha = execFileSync("git", ["rev-parse", "--short", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (sha) return sha; + } catch { + /* no git */ + } + return `nogit-${Date.now().toString(36)}`; +} + +interface CellResult { + endpoint: string; + N: number; + C: number; + ops: number; + errors: number; + wall_ms: number; + throughput_per_sec: number; + p50_ms: number; + p90_ms: number; + p99_ms: number; + min_ms: number; + max_ms: number; +} + +/** + * Issue `total` requests against `fetcher`, capped at `concurrency` + * in-flight at any moment. Records per-request latency in ms. + */ +async function driveLoad( + concurrency: number, + total: number, + fetcher: (i: number) => Promise, +): Promise<{ latencies: number[]; errors: number; wallMs: number }> { + const latencies: number[] = []; + let errors = 0; + let issued = 0; + const wallStart = performance.now(); + + async function worker(): Promise { + while (true) { + const i = issued++; + if (i >= total) return; + const t0 = performance.now(); + try { + await fetcher(i); + latencies.push(performance.now() - t0); + } catch { + errors++; + } + } + } + + const workers = Array.from({ length: Math.max(1, concurrency) }, () => + worker(), + ); + await Promise.allSettled(workers); + const wallMs = performance.now() - wallStart; + return { latencies, errors, wallMs }; +} + +function summarize( + endpoint: string, + N: number, + C: number, + latencies: number[], + errors: number, + wallMs: number, +): CellResult { + const sorted = latencies.slice().sort((a, b) => a - b); + const ops = sorted.length; + return { + endpoint, + N, + C, + ops, + errors, + wall_ms: Math.round(wallMs * 1000) / 1000, + throughput_per_sec: + wallMs > 0 ? Math.round((ops / (wallMs / 1000)) * 100) / 100 : 0, + p50_ms: Math.round(pXX(sorted, 50) * 1000) / 1000, + p90_ms: Math.round(pXX(sorted, 90) * 1000) / 1000, + p99_ms: Math.round(pXX(sorted, 99) * 1000) / 1000, + min_ms: ops > 0 ? Math.round(sorted[0]! * 1000) / 1000 : NaN, + max_ms: ops > 0 ? Math.round(sorted[ops - 1]! * 1000) / 1000 : NaN, + }; +} + +async function seedMemories( + baseUrl: string, + count: number, + rng: () => number, + seedConcurrency = 32, +): Promise<{ seeded: number; errors: number; wallMs: number }> { + let issued = 0; + let seeded = 0; + let errors = 0; + const t0 = performance.now(); + async function worker(): Promise { + while (true) { + const i = issued++; + if (i >= count) return; + const body = JSON.stringify({ + content: buildContent(rng, i), + type: "observation", + }); + try { + const res = await fetch(`${baseUrl}/agentmemory/remember`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + signal: AbortSignal.timeout(30_000), + }); + if (res.ok) { + seeded++; + } else { + errors++; + } + // drain body to free the socket + await res.text().catch(() => ""); + } catch { + errors++; + } + } + } + await Promise.allSettled( + Array.from({ length: seedConcurrency }, () => worker()), + ); + return { seeded, errors, wallMs: performance.now() - t0 }; +} + +async function measureRemember( + baseUrl: string, + rng: () => number, + N: number, + C: number, + ops: number, +): Promise { + const { latencies, errors, wallMs } = await driveLoad(C, ops, async (i) => { + const body = JSON.stringify({ + content: buildContent(rng, N + i), + type: "observation", + }); + const res = await fetch(`${baseUrl}/agentmemory/remember`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + signal: AbortSignal.timeout(30_000), + }); + await res.text().catch(() => ""); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + }); + return summarize("POST /agentmemory/remember", N, C, latencies, errors, wallMs); +} + +async function measureSmartSearch( + baseUrl: string, + rng: () => number, + N: number, + C: number, + ops: number, +): Promise { + const queries = Array.from({ length: 32 }, (_, i) => buildContent(rng, i)); + const { latencies, errors, wallMs } = await driveLoad(C, ops, async (i) => { + const body = JSON.stringify({ + query: queries[i % queries.length], + limit: 5, + }); + const res = await fetch(`${baseUrl}/agentmemory/smart-search`, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + signal: AbortSignal.timeout(30_000), + }); + await res.text().catch(() => ""); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + }); + return summarize( + "POST /agentmemory/smart-search", + N, + C, + latencies, + errors, + wallMs, + ); +} + +async function measureMemoriesLatest( + baseUrl: string, + N: number, + C: number, + ops: number, +): Promise { + const { latencies, errors, wallMs } = await driveLoad(C, ops, async () => { + const res = await fetch(`${baseUrl}/agentmemory/memories?latest=true`, { + method: "GET", + signal: AbortSignal.timeout(30_000), + }); + await res.text().catch(() => ""); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + }); + return summarize( + "GET /agentmemory/memories?latest=true", + N, + C, + latencies, + errors, + wallMs, + ); +} + +interface RunReport { + schema_version: 1; + generated_at: string; + git_sha: string; + base_url: string; + seed: number; + matrix: { N: number[]; C: number[] }; + ops_per_cell: number; + cells: CellResult[]; + notes: string; +} + +async function main(): Promise { + const cfg = loadConfig(); + console.log( + `[load-100k] base=${cfg.baseUrl} N=${cfg.Ns.join(",")} C=${cfg.Cs.join(",")} ops/cell=${cfg.opsPerCell} seed=${cfg.seed}`, + ); + + let daemon: ChildProcess | null = null; + if (cfg.autoStart) { + console.log("[load-100k] AGENTMEMORY_BENCH_AUTOSTART=1 — spawning daemon"); + daemon = maybeStartDaemon(); + } + + try { + console.log("[load-100k] waiting for /agentmemory/livez (timeout 30s)"); + await waitForLivez(cfg.baseUrl, 30_000); + + const cells: CellResult[] = []; + // N sorted ascending so each cell builds on the previous seed work. + const sortedNs = cfg.Ns.slice().sort((a, b) => a - b); + let seededSoFar = 0; + for (const N of sortedNs) { + const delta = N - seededSoFar; + if (delta > 0) { + console.log( + `[load-100k] seeding ${delta} memories (target N=${N})`, + ); + const rng = mulberry32(cfg.seed + seededSoFar); + const seedRes = await seedMemories(cfg.baseUrl, delta, rng); + seededSoFar += seedRes.seeded; + console.log( + `[load-100k] seeded=${seedRes.seeded} errors=${seedRes.errors} wall=${( + seedRes.wallMs / 1000 + ).toFixed(2)}s`, + ); + if (seedRes.errors > 0 && seedRes.seeded === 0) { + throw new Error( + `seeding produced 0 successes and ${seedRes.errors} errors — daemon misconfigured`, + ); + } + } + + for (const C of cfg.Cs) { + const probeRng = mulberry32(cfg.seed ^ (N * 0x9e3779b1) ^ C); + + console.log(`[load-100k] cell N=${N} C=${C} remember`); + const remember = await measureRemember( + cfg.baseUrl, + probeRng, + N, + C, + cfg.opsPerCell, + ); + cells.push(remember); + + console.log(`[load-100k] cell N=${N} C=${C} smart-search`); + const search = await measureSmartSearch( + cfg.baseUrl, + mulberry32(cfg.seed ^ (N * 0x85ebca77) ^ C), + N, + C, + cfg.opsPerCell, + ); + cells.push(search); + + console.log(`[load-100k] cell N=${N} C=${C} memories?latest=true`); + const memories = await measureMemoriesLatest( + cfg.baseUrl, + N, + C, + cfg.opsPerCell, + ); + cells.push(memories); + } + } + + const report: RunReport = { + schema_version: 1, + generated_at: new Date().toISOString(), + git_sha: shortGitSha(), + base_url: cfg.baseUrl, + seed: cfg.seed, + matrix: { N: sortedNs, C: cfg.Cs.slice() }, + ops_per_cell: cfg.opsPerCell, + cells, + notes: + "Single-process load harness. Latency in milliseconds. " + + "Throughput is wall-clock ops/sec for the cell (concurrent in-flight = C).", + }; + + mkdirSync(cfg.outDir, { recursive: true }); + const outPath = join(cfg.outDir, `load-100k-${report.git_sha}.json`); + writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf8"); + console.log(`[load-100k] wrote ${outPath} (${cells.length} cells)`); + + // Compact table to stdout for the verification commit. + console.log(""); + console.log( + [ + "endpoint".padEnd(40), + "N".padStart(8), + "C".padStart(4), + "ops".padStart(6), + "err".padStart(4), + "p50_ms".padStart(8), + "p90_ms".padStart(8), + "p99_ms".padStart(8), + "tp/s".padStart(9), + ].join(" "), + ); + for (const c of cells) { + console.log( + [ + c.endpoint.padEnd(40), + String(c.N).padStart(8), + String(c.C).padStart(4), + String(c.ops).padStart(6), + String(c.errors).padStart(4), + c.p50_ms.toFixed(2).padStart(8), + c.p90_ms.toFixed(2).padStart(8), + c.p99_ms.toFixed(2).padStart(8), + c.throughput_per_sec.toFixed(2).padStart(9), + ].join(" "), + ); + } + } finally { + if (daemon) { + daemon.kill("SIGTERM"); + // give it 2s to exit cleanly before SIGKILL + await new Promise((resolveFn) => { + const t = setTimeout(() => { + try { + daemon!.kill("SIGKILL"); + } catch { + /* already dead */ + } + resolveFn(); + }, 2000); + daemon!.once("exit", () => { + clearTimeout(t); + resolveFn(); + }); + }); + } + } +} + +main().catch((err) => { + console.error("[load-100k] failed:", err instanceof Error ? err.stack : err); + process.exit(1); +}); diff --git a/benchmark/longmemeval-bench.ts b/benchmark/longmemeval-bench.ts new file mode 100644 index 0000000..d826e1a --- /dev/null +++ b/benchmark/longmemeval-bench.ts @@ -0,0 +1,317 @@ +import { SearchIndex } from "../src/state/search-index.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import { HybridSearch } from "../src/state/hybrid-search.js"; +import type { + CompressedObservation, + EmbeddingProvider, +} from "../src/types.js"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; + +interface LongMemEvalEntry { + question_id: string; + question_type: string; + question: string; + question_date: string; + answer: string; + answer_session_ids: string[]; + haystack_dates: string[]; + haystack_session_ids: string[]; + haystack_sessions: Array>; +} + +interface SessionChunk { + sessionId: string; + text: string; + turnCount: number; +} + +interface BenchResult { + question_id: string; + question_type: string; + recall_any_at_5: number; + recall_any_at_10: number; + recall_any_at_20: number; + ndcg_at_10: number; + mrr: number; + retrieved_session_ids: string[]; + gold_session_ids: string[]; +} + +function chunkSessionToText( + turns: Array<{ role: string; content: string }>, +): string { + return turns + .map((t) => `${t.role}: ${t.content}`) + .join("\n"); +} + +function recallAny( + retrievedSessionIds: string[], + goldSessionIds: string[], + k: number, +): number { + const topK = new Set(retrievedSessionIds.slice(0, k)); + return goldSessionIds.some((gid) => topK.has(gid)) ? 1.0 : 0.0; +} + +function dcg(relevances: boolean[], k: number): number { + let sum = 0; + for (let i = 0; i < Math.min(k, relevances.length); i++) { + sum += (relevances[i] ? 1 : 0) / Math.log2(i + 2); + } + return sum; +} + +function ndcg( + retrievedSessionIds: string[], + goldSessionIds: Set, + k: number, +): number { + const rels = retrievedSessionIds + .slice(0, k) + .map((id) => goldSessionIds.has(id)); + const idealRels = Array.from( + { length: Math.min(k, goldSessionIds.size) }, + () => true, + ); + const idealDCG = dcg(idealRels, k); + if (idealDCG === 0) return 0; + return dcg(rels, k) / idealDCG; +} + +function mrr( + retrievedSessionIds: string[], + goldSessionIds: Set, +): number { + for (let i = 0; i < retrievedSessionIds.length; i++) { + if (goldSessionIds.has(retrievedSessionIds[i])) return 1 / (i + 1); + } + return 0; +} + +class MockKV { + private store = new Map>(); + async get(scope: string, key: string): Promise { + const m = this.store.get(scope); + if (!m || !m.has(key)) throw new Error(`Not found: ${scope}/${key}`); + return m.get(key) as T; + } + async set(scope: string, key: string, value: unknown): Promise { + if (!this.store.has(scope)) this.store.set(scope, new Map()); + this.store.get(scope)!.set(key, value); + } + async list(scope: string): Promise { + const m = this.store.get(scope); + if (!m) return []; + return Array.from(m.values()) as T[]; + } + async delete(scope: string, key: string): Promise { + this.store.get(scope)?.delete(key); + } +} + +async function runBenchmark( + mode: "bm25" | "vector" | "hybrid", + embeddingProvider?: EmbeddingProvider, +) { + const dataPath = new URL("./data/longmemeval_s_cleaned.json", import.meta.url).pathname; + if (!existsSync(dataPath)) { + console.error(`Dataset not found at ${dataPath}`); + console.error("Download from: https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned"); + process.exit(1); + } + + console.log(`Loading LongMemEval-S dataset...`); + const raw = JSON.parse(readFileSync(dataPath, "utf-8")) as LongMemEvalEntry[]; + + const abstentionTypes = new Set([ + "single-session-user_abs", + "multi-session_abs", + "knowledge-update_abs", + "temporal-reasoning_abs", + ]); + const entries = raw.filter((e) => !abstentionTypes.has(e.question_type)); + console.log( + `Loaded ${entries.length} questions (${raw.length - entries.length} abstention excluded)`, + ); + + const results: BenchResult[] = []; + let processed = 0; + + for (const entry of entries) { + const sessionChunks: SessionChunk[] = []; + for (let i = 0; i < entry.haystack_sessions.length; i++) { + const sessionId = entry.haystack_session_ids[i]; + const turns = entry.haystack_sessions[i]; + const text = chunkSessionToText(turns); + sessionChunks.push({ sessionId, text, turnCount: turns.length }); + } + + const bm25 = new SearchIndex(); + const vector = mode !== "bm25" ? new VectorIndex() : null; + const kv = new MockKV(); + + const observations: CompressedObservation[] = []; + for (const chunk of sessionChunks) { + const obs: CompressedObservation = { + id: `obs_${chunk.sessionId}`, + sessionId: chunk.sessionId, + timestamp: new Date().toISOString(), + type: "conversation", + title: chunk.text.slice(0, 80), + facts: [], + narrative: chunk.text, + concepts: [], + files: [], + importance: 5, + }; + observations.push(obs); + bm25.add(obs); + + if (vector && embeddingProvider) { + try { + const embedding = await embeddingProvider.embed( + chunk.text.slice(0, 512), + ); + vector.add(obs.id, chunk.sessionId, embedding); + } catch {} + } + + await kv.set(`mem:obs:${chunk.sessionId}`, obs.id, obs); + } + + let retrievedObsIds: string[]; + + if (mode === "bm25") { + const bm25Results = bm25.search(entry.question, 20); + retrievedObsIds = bm25Results.map((r) => r.obsId); + } else { + const hybridSearch = new HybridSearch( + bm25, + vector, + embeddingProvider || null, + kv as any, + 0.4, + 0.6, + 0.0, + false, + ); + const hybridResults = await hybridSearch.search(entry.question, 20); + retrievedObsIds = hybridResults.map((r) => r.observation.id); + } + + const retrievedSessionIds = retrievedObsIds.map((oid) => + oid.replace(/^obs_/, ""), + ); + const goldSet = new Set(entry.answer_session_ids); + + const result: BenchResult = { + question_id: entry.question_id, + question_type: entry.question_type, + recall_any_at_5: recallAny(retrievedSessionIds, entry.answer_session_ids, 5), + recall_any_at_10: recallAny(retrievedSessionIds, entry.answer_session_ids, 10), + recall_any_at_20: recallAny(retrievedSessionIds, entry.answer_session_ids, 20), + ndcg_at_10: ndcg(retrievedSessionIds, goldSet, 10), + mrr: mrr(retrievedSessionIds, goldSet), + retrieved_session_ids: retrievedSessionIds.slice(0, 10), + gold_session_ids: entry.answer_session_ids, + }; + results.push(result); + processed++; + + if (processed % 50 === 0) { + const avgRecall5 = + results.reduce((s, r) => s + r.recall_any_at_5, 0) / results.length; + console.log( + ` [${processed}/${entries.length}] running recall_any@5: ${(avgRecall5 * 100).toFixed(1)}%`, + ); + } + } + + const avgRecallAny5 = + results.reduce((s, r) => s + r.recall_any_at_5, 0) / results.length; + const avgRecallAny10 = + results.reduce((s, r) => s + r.recall_any_at_10, 0) / results.length; + const avgRecallAny20 = + results.reduce((s, r) => s + r.recall_any_at_20, 0) / results.length; + const avgNdcg10 = + results.reduce((s, r) => s + r.ndcg_at_10, 0) / results.length; + const avgMrr = + results.reduce((s, r) => s + r.mrr, 0) / results.length; + + const byType = new Map(); + for (const r of results) { + if (!byType.has(r.question_type)) byType.set(r.question_type, []); + byType.get(r.question_type)!.push(r); + } + + console.log(`\n=== LongMemEval-S Results (${mode}) ===`); + console.log(`Questions: ${results.length} (excl. abstention)`); + console.log(`recall_any@5: ${(avgRecallAny5 * 100).toFixed(1)}%`); + console.log(`recall_any@10: ${(avgRecallAny10 * 100).toFixed(1)}%`); + console.log(`recall_any@20: ${(avgRecallAny20 * 100).toFixed(1)}%`); + console.log(`NDCG@10: ${(avgNdcg10 * 100).toFixed(1)}%`); + console.log(`MRR: ${(avgMrr * 100).toFixed(1)}%`); + + console.log(`\nBy question type:`); + for (const [type, typeResults] of byType) { + const r5 = + typeResults.reduce((s, r) => s + r.recall_any_at_5, 0) / + typeResults.length; + const r10 = + typeResults.reduce((s, r) => s + r.recall_any_at_10, 0) / + typeResults.length; + console.log( + ` ${type.padEnd(30)} R@5: ${(r5 * 100).toFixed(1)}% R@10: ${(r10 * 100).toFixed(1)}% (n=${typeResults.length})`, + ); + } + + const outPath = new URL( + `./data/longmemeval_results_${mode}.json`, + import.meta.url, + ).pathname; + writeFileSync( + outPath, + JSON.stringify( + { + mode, + questions: results.length, + recall_any_at_5: avgRecallAny5, + recall_any_at_10: avgRecallAny10, + recall_any_at_20: avgRecallAny20, + ndcg_at_10: avgNdcg10, + mrr: avgMrr, + per_type: Object.fromEntries( + Array.from(byType).map(([type, tr]) => [ + type, + { + count: tr.length, + recall_any_at_5: + tr.reduce((s, r) => s + r.recall_any_at_5, 0) / tr.length, + recall_any_at_10: + tr.reduce((s, r) => s + r.recall_any_at_10, 0) / tr.length, + }, + ]), + ), + per_question: results, + }, + null, + 2, + ), + ); + console.log(`\nResults saved to ${outPath}`); +} + +const mode = (process.argv[2] || "bm25") as "bm25" | "vector" | "hybrid"; +console.log(`Running LongMemEval-S benchmark in ${mode} mode...`); + +if (mode === "bm25") { + runBenchmark("bm25").catch(console.error); +} else { + import("../src/providers/embedding/local.js") + .then(({ LocalEmbeddingProvider }) => { + const provider = new LocalEmbeddingProvider(); + return runBenchmark(mode, provider); + }) + .catch(console.error); +} diff --git a/benchmark/quality-eval.ts b/benchmark/quality-eval.ts new file mode 100644 index 0000000..cd46fbc --- /dev/null +++ b/benchmark/quality-eval.ts @@ -0,0 +1,643 @@ +import { SearchIndex } from "../src/state/search-index.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import { HybridSearch } from "../src/state/hybrid-search.js"; +import { GraphRetrieval } from "../src/functions/graph-retrieval.js"; +import { extractEntitiesFromQuery } from "../src/functions/query-expansion.js"; +import type { CompressedObservation, GraphNode, GraphEdge, GraphEdgeType } from "../src/types.js"; +import { generateDataset, type LabeledQuery } from "./dataset.js"; +import { writeFileSync } from "node:fs"; + +interface QualityMetrics { + query: string; + category: string; + recall_at_5: number; + recall_at_10: number; + recall_at_20: number; + precision_at_5: number; + precision_at_10: number; + ndcg_at_10: number; + mrr: number; + relevant_count: number; + retrieved_count: number; + latency_ms: number; +} + +interface SystemMetrics { + system: string; + avg_recall_at_5: number; + avg_recall_at_10: number; + avg_recall_at_20: number; + avg_precision_at_5: number; + avg_precision_at_10: number; + avg_ndcg_at_10: number; + avg_mrr: number; + avg_latency_ms: number; + total_tokens_per_query: number; + per_query: QualityMetrics[]; +} + +function dcg(relevances: boolean[], k: number): number { + let sum = 0; + for (let i = 0; i < Math.min(k, relevances.length); i++) { + sum += (relevances[i] ? 1 : 0) / Math.log2(i + 2); + } + return sum; +} + +function ndcg(retrieved: string[], relevant: Set, k: number): number { + const actualRelevances = retrieved.slice(0, k).map(id => relevant.has(id)); + const idealRelevances = Array.from({ length: Math.min(k, relevant.size) }, () => true); + const idealDCG = dcg(idealRelevances, k); + if (idealDCG === 0) return 0; + return dcg(actualRelevances, k) / idealDCG; +} + +function recall(retrieved: string[], relevant: Set, k: number): number { + if (relevant.size === 0) return 1; + const topK = new Set(retrieved.slice(0, k)); + let hits = 0; + for (const id of relevant) { + if (topK.has(id)) hits++; + } + return hits / relevant.size; +} + +function precision(retrieved: string[], relevant: Set, k: number): number { + const topK = retrieved.slice(0, k); + if (topK.length === 0) return 0; + let hits = 0; + for (const id of topK) { + if (relevant.has(id)) hits++; + } + return hits / topK.length; +} + +function mrr(retrieved: string[], relevant: Set): number { + for (let i = 0; i < retrieved.length; i++) { + if (relevant.has(retrieved[i])) return 1 / (i + 1); + } + return 0; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function deterministicEmbedding(text: string, dims = 384): Float32Array { + const arr = new Float32Array(dims); + const words = text.toLowerCase().split(/\W+/).filter(w => w.length > 2); + for (const word of words) { + for (let i = 0; i < word.length; i++) { + const idx = (word.charCodeAt(i) * 31 + i * 17) % dims; + arr[idx] += 1; + const idx2 = (word.charCodeAt(i) * 37 + i * 13 + word.length * 7) % dims; + arr[idx2] += 0.5; + } + } + const norm = Math.sqrt(arr.reduce((s, v) => s + v * v, 0)); + if (norm > 0) for (let i = 0; i < dims; i++) arr[i] /= norm; + return arr; +} + +async function evalBm25Only( + observations: CompressedObservation[], + queries: LabeledQuery[], +): Promise { + const index = new SearchIndex(); + for (const obs of observations) index.add(obs); + + const perQuery: QualityMetrics[] = []; + + for (const q of queries) { + const relevant = new Set(q.relevantObsIds); + const start = performance.now(); + const results = index.search(q.query, 20); + const latency = performance.now() - start; + + const retrieved = results.map(r => r.obsId); + perQuery.push({ + query: q.query, + category: q.category, + recall_at_5: recall(retrieved, relevant, 5), + recall_at_10: recall(retrieved, relevant, 10), + recall_at_20: recall(retrieved, relevant, 20), + precision_at_5: precision(retrieved, relevant, 5), + precision_at_10: precision(retrieved, relevant, 10), + ndcg_at_10: ndcg(retrieved, relevant, 10), + mrr: mrr(retrieved, relevant), + relevant_count: relevant.size, + retrieved_count: results.length, + latency_ms: latency, + }); + } + + const avgTokens = perQuery.reduce((sum, q) => sum + q.retrieved_count, 0) / perQuery.length; + const avgObsTokens = observations.slice(0, 50).reduce((s, o) => s + estimateTokens(JSON.stringify(o)), 0) / 50; + + return { + system: "BM25-only", + avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)), + avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)), + avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)), + avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)), + avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)), + avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)), + avg_mrr: avg(perQuery.map(q => q.mrr)), + avg_latency_ms: avg(perQuery.map(q => q.latency_ms)), + total_tokens_per_query: Math.round(avgObsTokens * avgTokens), + per_query: perQuery, + }; +} + +async function evalDualStream( + observations: CompressedObservation[], + queries: LabeledQuery[], +): Promise { + const kv = mockKV(); + const bm25 = new SearchIndex(); + const vector = new VectorIndex(); + const dims = 384; + + for (const obs of observations) { + bm25.add(obs); + const text = [obs.title, obs.narrative, ...obs.concepts, ...obs.facts].join(" "); + vector.add(obs.id, obs.sessionId, deterministicEmbedding(text, dims)); + await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs); + } + + const mockEmbed: any = { + name: "deterministic", + dimensions: dims, + embed: async (text: string) => deterministicEmbedding(text, dims), + embedBatch: async (texts: string[]) => texts.map(t => deterministicEmbedding(t, dims)), + }; + + const hybrid = new HybridSearch(bm25, vector, mockEmbed, kv as never, 0.4, 0.6, 0); + const perQuery: QualityMetrics[] = []; + + for (const q of queries) { + const relevant = new Set(q.relevantObsIds); + const start = performance.now(); + const results = await hybrid.search(q.query, 20); + const latency = performance.now() - start; + + const retrieved = results.map(r => r.observation.id); + perQuery.push({ + query: q.query, + category: q.category, + recall_at_5: recall(retrieved, relevant, 5), + recall_at_10: recall(retrieved, relevant, 10), + recall_at_20: recall(retrieved, relevant, 20), + precision_at_5: precision(retrieved, relevant, 5), + precision_at_10: precision(retrieved, relevant, 10), + ndcg_at_10: ndcg(retrieved, relevant, 10), + mrr: mrr(retrieved, relevant), + relevant_count: relevant.size, + retrieved_count: results.length, + latency_ms: latency, + }); + } + + const avgResultTokens = perQuery.reduce((sum, q) => { + return sum + q.retrieved_count; + }, 0) / perQuery.length; + const avgObsTokens2 = observations.slice(0, 50).reduce((s, o) => s + estimateTokens(JSON.stringify(o)), 0) / 50; + + return { + system: "Dual-stream (BM25+Vector)", + avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)), + avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)), + avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)), + avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)), + avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)), + avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)), + avg_mrr: avg(perQuery.map(q => q.mrr)), + avg_latency_ms: avg(perQuery.map(q => q.latency_ms)), + total_tokens_per_query: Math.round(avgObsTokens2 * avgResultTokens), + per_query: perQuery, + }; +} + +async function evalTripleStream( + observations: CompressedObservation[], + queries: LabeledQuery[], +): Promise { + const kv = mockKV(); + const bm25 = new SearchIndex(); + const vector = new VectorIndex(); + const dims = 384; + + for (const obs of observations) { + bm25.add(obs); + const text = [obs.title, obs.narrative, ...obs.concepts, ...obs.facts].join(" "); + vector.add(obs.id, obs.sessionId, deterministicEmbedding(text, dims)); + await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs); + } + + const conceptToNodes = new Map(); + const nodeTypes: GraphNode["type"][] = ["concept", "library", "file", "pattern"]; + const edgeTypes: GraphEdgeType[] = ["uses", "related_to", "depends_on", "modifies"]; + const now = new Date().toISOString(); + let nodeId = 0; + + for (const obs of observations) { + for (const concept of obs.concepts) { + if (!conceptToNodes.has(concept)) { + const nid = `gn_${nodeId++}`; + conceptToNodes.set(concept, nid); + await kv.set("mem:graph:nodes", nid, { + id: nid, + type: nodeTypes[nodeId % nodeTypes.length], + name: concept, + properties: {}, + sourceObservationIds: [], + createdAt: now, + } as GraphNode); + } + const nid = conceptToNodes.get(concept)!; + const existing = await kv.get("mem:graph:nodes", nid); + if (existing && !existing.sourceObservationIds.includes(obs.id)) { + existing.sourceObservationIds.push(obs.id); + await kv.set("mem:graph:nodes", nid, existing); + } + } + + const capped = obs.concepts.slice(0, 10); + for (let i = 0; i < capped.length; i++) { + for (let j = i + 1; j < capped.length; j++) { + const srcNid = conceptToNodes.get(capped[i])!; + const tgtNid = conceptToNodes.get(capped[j])!; + if (srcNid && tgtNid && srcNid !== tgtNid) { + const eid = `ge_${srcNid}_${tgtNid}`; + const existing = await kv.get("mem:graph:edges", eid); + const weight = existing ? Math.min(1.0, existing.weight + 0.1) : 0.5; + await kv.set("mem:graph:edges", eid, { + id: eid, + type: edgeTypes[(i + j) % edgeTypes.length], + sourceNodeId: srcNid, + targetNodeId: tgtNid, + weight, + sourceObservationIds: existing + ? [...new Set([...existing.sourceObservationIds, obs.id])] + : [obs.id], + createdAt: now, + tcommit: now, + version: 1, + isLatest: true, + } as GraphEdge); + } + } + } + } + + const mockEmbed: any = { + name: "deterministic", + dimensions: dims, + embed: async (text: string) => deterministicEmbedding(text, dims), + embedBatch: async (texts: string[]) => texts.map(t => deterministicEmbedding(t, dims)), + }; + + const hybrid = new HybridSearch(bm25, vector, mockEmbed, kv as never, 0.4, 0.6, 0.3); + const perQuery: QualityMetrics[] = []; + + for (const q of queries) { + const relevant = new Set(q.relevantObsIds); + const start = performance.now(); + const results = await hybrid.search(q.query, 20); + const latency = performance.now() - start; + + const retrieved = results.map(r => r.observation.id); + perQuery.push({ + query: q.query, + category: q.category, + recall_at_5: recall(retrieved, relevant, 5), + recall_at_10: recall(retrieved, relevant, 10), + recall_at_20: recall(retrieved, relevant, 20), + precision_at_5: precision(retrieved, relevant, 5), + precision_at_10: precision(retrieved, relevant, 10), + ndcg_at_10: ndcg(retrieved, relevant, 10), + mrr: mrr(retrieved, relevant), + relevant_count: relevant.size, + retrieved_count: results.length, + latency_ms: latency, + }); + } + + const avgResultTokens3 = perQuery.reduce((sum, q) => { + return sum + q.retrieved_count; + }, 0) / perQuery.length; + const avgObsTokens3 = observations.slice(0, 50).reduce((s, o) => s + estimateTokens(JSON.stringify(o)), 0) / 50; + + return { + system: "Triple-stream (BM25+Vector+Graph)", + avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)), + avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)), + avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)), + avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)), + avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)), + avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)), + avg_mrr: avg(perQuery.map(q => q.mrr)), + avg_latency_ms: avg(perQuery.map(q => q.latency_ms)), + total_tokens_per_query: Math.round(avgObsTokens3 * avgResultTokens3), + per_query: perQuery, + }; +} + +async function evalBuiltinMemory( + observations: CompressedObservation[], + queries: LabeledQuery[], +): Promise { + const allText = observations.map(o => + `## ${o.title}\n${o.narrative}\nConcepts: ${o.concepts.join(", ")}\nFiles: ${o.files.join(", ")}` + ).join("\n\n"); + + const totalTokens = estimateTokens(allText); + + const perQuery: QualityMetrics[] = []; + + for (const q of queries) { + const relevant = new Set(q.relevantObsIds); + const start = performance.now(); + + const queryTerms = q.query.toLowerCase().split(/\W+/).filter(w => w.length > 2); + const scored: Array<{ id: string; score: number }> = []; + + for (const obs of observations) { + const text = [obs.title, obs.narrative, ...obs.concepts, ...obs.facts].join(" ").toLowerCase(); + let score = 0; + for (const term of queryTerms) { + if (text.includes(term)) score++; + } + if (score > 0) scored.push({ id: obs.id, score }); + } + + scored.sort((a, b) => b.score - a.score); + const latency = performance.now() - start; + + const retrieved = scored.map(s => s.id).slice(0, 20); + perQuery.push({ + query: q.query, + category: q.category, + recall_at_5: recall(retrieved, relevant, 5), + recall_at_10: recall(retrieved, relevant, 10), + recall_at_20: recall(retrieved, relevant, 20), + precision_at_5: precision(retrieved, relevant, 5), + precision_at_10: precision(retrieved, relevant, 10), + ndcg_at_10: ndcg(retrieved, relevant, 10), + mrr: mrr(retrieved, relevant), + relevant_count: relevant.size, + retrieved_count: Math.min(scored.length, 20), + latency_ms: latency, + }); + } + + return { + system: "Built-in (CLAUDE.md / grep)", + avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)), + avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)), + avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)), + avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)), + avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)), + avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)), + avg_mrr: avg(perQuery.map(q => q.mrr)), + avg_latency_ms: avg(perQuery.map(q => q.latency_ms)), + total_tokens_per_query: totalTokens, + per_query: perQuery, + }; +} + +async function evalBuiltinMemoryTruncated( + observations: CompressedObservation[], + queries: LabeledQuery[], +): Promise { + const MAX_LINES = 200; + const lines = observations.map(o => + `- ${o.title}: ${o.narrative.slice(0, 80)}... [${o.concepts.slice(0, 3).join(", ")}]` + ); + const truncated = lines.slice(0, MAX_LINES); + const truncatedIds = new Set(observations.slice(0, MAX_LINES).map(o => o.id)); + const totalTokens = estimateTokens(truncated.join("\n")); + + const perQuery: QualityMetrics[] = []; + + for (const q of queries) { + const relevant = new Set(q.relevantObsIds); + const start = performance.now(); + + const queryTerms = q.query.toLowerCase().split(/\W+/).filter(w => w.length > 2); + const scored: Array<{ id: string; score: number }> = []; + + for (let i = 0; i < Math.min(MAX_LINES, observations.length); i++) { + const obs = observations[i]; + const line = truncated[i]; + let score = 0; + for (const term of queryTerms) { + if (line.toLowerCase().includes(term)) score++; + } + if (score > 0) scored.push({ id: obs.id, score }); + } + + scored.sort((a, b) => b.score - a.score); + const latency = performance.now() - start; + + const retrieved = scored.map(s => s.id).slice(0, 20); + + const reachableRelevant = new Set( + [...relevant].filter(id => truncatedIds.has(id)) + ); + + perQuery.push({ + query: q.query, + category: q.category, + recall_at_5: recall(retrieved, relevant, 5), + recall_at_10: recall(retrieved, relevant, 10), + recall_at_20: recall(retrieved, relevant, 20), + precision_at_5: precision(retrieved, relevant, 5), + precision_at_10: precision(retrieved, relevant, 10), + ndcg_at_10: ndcg(retrieved, relevant, 10), + mrr: mrr(retrieved, relevant), + relevant_count: relevant.size, + retrieved_count: Math.min(scored.length, 20), + latency_ms: latency, + }); + } + + return { + system: "Built-in (200-line MEMORY.md)", + avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)), + avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)), + avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)), + avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)), + avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)), + avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)), + avg_mrr: avg(perQuery.map(q => q.mrr)), + avg_latency_ms: avg(perQuery.map(q => q.latency_ms)), + total_tokens_per_query: totalTokens, + per_query: perQuery, + }; +} + +function avg(nums: number[]): number { + return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0; +} + +function pct(n: number): string { + return (n * 100).toFixed(1) + "%"; +} + +function generateReport(systems: SystemMetrics[], obsCount: number, queryCount: number): string { + const lines: string[] = []; + const w = (s: string) => lines.push(s); + + w("# agentmemory v0.6.0 — Search Quality Evaluation"); + w(""); + w(`**Date:** ${new Date().toISOString()}`); + w(`**Dataset:** ${obsCount} observations across 30 sessions (realistic coding project)`); + w(`**Queries:** ${queryCount} labeled queries with ground-truth relevance`); + w(`**Metric definitions:** Recall@K (fraction of relevant docs in top K), Precision@K (fraction of top K that are relevant), NDCG@10 (ranking quality), MRR (position of first relevant result)`); + w(""); + + w("## Head-to-Head Comparison"); + w(""); + w("| System | Recall@5 | Recall@10 | Precision@5 | NDCG@10 | MRR | Latency | Tokens/query |"); + w("|--------|----------|-----------|-------------|---------|-----|---------|--------------|"); + for (const s of systems) { + w(`| ${s.system} | ${pct(s.avg_recall_at_5)} | ${pct(s.avg_recall_at_10)} | ${pct(s.avg_precision_at_5)} | ${pct(s.avg_ndcg_at_10)} | ${pct(s.avg_mrr)} | ${s.avg_latency_ms.toFixed(2)}ms | ${s.total_tokens_per_query.toLocaleString()} |`); + } + + w(""); + w("## Why This Matters"); + w(""); + + const builtin = systems.find(s => s.system.includes("CLAUDE.md / grep")); + const truncated = systems.find(s => s.system.includes("200-line")); + const triple = systems.find(s => s.system.includes("Triple")); + const bm25 = systems.find(s => s.system === "BM25-only"); + + if (builtin && triple) { + const recallLift = ((triple.avg_recall_at_10 - builtin.avg_recall_at_10) / Math.max(0.001, builtin.avg_recall_at_10) * 100); + const tokenSaving = ((1 - triple.total_tokens_per_query / builtin.total_tokens_per_query) * 100); + w(`**Recall improvement:** agentmemory triple-stream finds ${pct(triple.avg_recall_at_10)} of relevant memories at K=10 vs ${pct(builtin.avg_recall_at_10)} for keyword grep (${recallLift > 0 ? "+" : ""}${recallLift.toFixed(0)}%)`); + w(`**Token savings:** agentmemory returns only the top 10 results (${triple.total_tokens_per_query.toLocaleString()} tokens) vs loading everything into context (${builtin.total_tokens_per_query.toLocaleString()} tokens) — ${tokenSaving.toFixed(0)}% reduction`); + } + + if (truncated && triple) { + w(`**200-line cap:** Claude Code's MEMORY.md is capped at 200 lines. With ${obsCount} observations, ${pct(truncated.avg_recall_at_10)} recall at K=10 — memories from later sessions are simply invisible.`); + } + + w(""); + w("## Per-Query Breakdown (Triple-Stream)"); + w(""); + + if (triple) { + w("| Query | Category | Recall@10 | NDCG@10 | MRR | Relevant | Latency |"); + w("|-------|----------|-----------|---------|-----|----------|---------|"); + for (const q of triple.per_query) { + w(`| ${q.query.slice(0, 45)}${q.query.length > 45 ? "..." : ""} | ${q.category} | ${pct(q.recall_at_10)} | ${pct(q.ndcg_at_10)} | ${pct(q.mrr)} | ${q.relevant_count} | ${q.latency_ms.toFixed(1)}ms |`); + } + } + + w(""); + w("## By Query Category"); + w(""); + + const categories = ["exact", "semantic", "cross-session", "entity"]; + if (triple) { + w("| Category | Avg Recall@10 | Avg NDCG@10 | Avg MRR | Queries |"); + w("|----------|---------------|-------------|---------|---------|"); + for (const cat of categories) { + const qs = triple.per_query.filter(q => q.category === cat); + if (qs.length === 0) continue; + w(`| ${cat} | ${pct(avg(qs.map(q => q.recall_at_10)))} | ${pct(avg(qs.map(q => q.ndcg_at_10)))} | ${pct(avg(qs.map(q => q.mrr)))} | ${qs.length} |`); + } + } + + w(""); + w("## Context Window Analysis"); + w(""); + w("The fundamental problem with built-in agent memory:"); + w(""); + w("| Observations | MEMORY.md tokens | agentmemory tokens (top 10) | Savings | MEMORY.md reachable |"); + w("|-------------|-----------------|---------------------------|---------|-------------------|"); + + for (const count of [240, 500, 1000, 5000]) { + const memTokens = Math.round(count * 50); + const amTokens = triple ? triple.total_tokens_per_query : 500; + const saving = ((1 - amTokens / memTokens) * 100); + const reachable = count <= 200 ? "100%" : `${((200 / count) * 100).toFixed(0)}%`; + w(`| ${count.toLocaleString()} | ${memTokens.toLocaleString()} | ${amTokens.toLocaleString()} | ${saving.toFixed(0)}% | ${reachable} |`); + } + + w(""); + w("At 240 observations (our dataset), MEMORY.md already hits its 200-line cap and loses access to the most recent 40 observations. At 1,000 observations, 80% of memories are invisible. agentmemory always searches the full corpus."); + + w(""); + w("---"); + w(""); + w(`*${systems.reduce((s, sys) => s + sys.per_query.length, 0)} evaluations across ${systems.length} systems. Ground-truth labels assigned by concept matching against observation metadata.*`); + + return lines.join("\n"); +} + +async function main() { + console.log("Generating labeled dataset..."); + const { observations, queries, sessions } = generateDataset(); + console.log(`Dataset: ${observations.length} observations, ${sessions.size} sessions, ${queries.length} queries`); + console.log(`Avg relevant docs per query: ${(queries.reduce((s, q) => s + q.relevantObsIds.length, 0) / queries.length).toFixed(1)}`); + console.log(""); + + console.log("Evaluating: Built-in (CLAUDE.md / grep)..."); + const builtinResults = await evalBuiltinMemory(observations, queries); + console.log(` Recall@10: ${pct(builtinResults.avg_recall_at_10)}, NDCG@10: ${pct(builtinResults.avg_ndcg_at_10)}`); + + console.log("Evaluating: Built-in (200-line MEMORY.md)..."); + const truncatedResults = await evalBuiltinMemoryTruncated(observations, queries); + console.log(` Recall@10: ${pct(truncatedResults.avg_recall_at_10)}, NDCG@10: ${pct(truncatedResults.avg_ndcg_at_10)}`); + + console.log("Evaluating: BM25-only..."); + const bm25Results = await evalBm25Only(observations, queries); + console.log(` Recall@10: ${pct(bm25Results.avg_recall_at_10)}, NDCG@10: ${pct(bm25Results.avg_ndcg_at_10)}`); + + console.log("Evaluating: Dual-stream (BM25+Vector)..."); + const dualResults = await evalDualStream(observations, queries); + console.log(` Recall@10: ${pct(dualResults.avg_recall_at_10)}, NDCG@10: ${pct(dualResults.avg_ndcg_at_10)}`); + + console.log("Evaluating: Triple-stream (BM25+Vector+Graph)..."); + const tripleResults = await evalTripleStream(observations, queries); + console.log(` Recall@10: ${pct(tripleResults.avg_recall_at_10)}, NDCG@10: ${pct(tripleResults.avg_ndcg_at_10)}`); + + console.log(""); + + const report = generateReport( + [builtinResults, truncatedResults, bm25Results, dualResults, tripleResults], + observations.length, + queries.length, + ); + + writeFileSync("benchmark/QUALITY.md", report); + console.log(report); + console.log(`\nReport written to benchmark/QUALITY.md`); +} + +main().catch(console.error); diff --git a/benchmark/real-embeddings-eval.ts b/benchmark/real-embeddings-eval.ts new file mode 100644 index 0000000..1e4628e --- /dev/null +++ b/benchmark/real-embeddings-eval.ts @@ -0,0 +1,405 @@ +import { SearchIndex } from "../src/state/search-index.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import { HybridSearch } from "../src/state/hybrid-search.js"; +import { LocalEmbeddingProvider } from "../src/providers/embedding/local.js"; +import type { CompressedObservation, EmbeddingProvider } from "../src/types.js"; +import { generateDataset, type LabeledQuery } from "./dataset.js"; +import { writeFileSync } from "node:fs"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function obsToText(obs: CompressedObservation): string { + return [obs.title, obs.subtitle || "", obs.narrative, ...obs.facts, ...obs.concepts].join(" "); +} + +function recall(retrieved: string[], relevant: Set, k: number): number { + if (relevant.size === 0) return 1; + const topK = new Set(retrieved.slice(0, k)); + let hits = 0; + for (const id of relevant) if (topK.has(id)) hits++; + return hits / relevant.size; +} + +function precision(retrieved: string[], relevant: Set, k: number): number { + const topK = retrieved.slice(0, k); + if (topK.length === 0) return 0; + let hits = 0; + for (const id of topK) if (relevant.has(id)) hits++; + return hits / topK.length; +} + +function dcg(relevances: boolean[], k: number): number { + let sum = 0; + for (let i = 0; i < Math.min(k, relevances.length); i++) + sum += (relevances[i] ? 1 : 0) / Math.log2(i + 2); + return sum; +} + +function ndcg(retrieved: string[], relevant: Set, k: number): number { + const actual = retrieved.slice(0, k).map(id => relevant.has(id)); + const ideal = Array.from({ length: Math.min(k, relevant.size) }, () => true); + const idealDCG = dcg(ideal, k); + return idealDCG === 0 ? 0 : dcg(actual, k) / idealDCG; +} + +function mrr(retrieved: string[], relevant: Set): number { + for (let i = 0; i < retrieved.length; i++) + if (relevant.has(retrieved[i])) return 1 / (i + 1); + return 0; +} + +function avg(nums: number[]): number { + return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0; +} + +function pct(n: number): string { + return (n * 100).toFixed(1) + "%"; +} + +interface QueryResult { + query: string; + category: string; + recall_5: number; + recall_10: number; + precision_5: number; + ndcg_10: number; + mrr_val: number; + relevant_count: number; + latency_ms: number; +} + +interface SystemResult { + name: string; + results: QueryResult[]; + embed_time_ms: number; + tokens_per_query: number; +} + +async function evalSystem( + name: string, + observations: CompressedObservation[], + queries: LabeledQuery[], + provider: EmbeddingProvider | null, + weights: { bm25: number; vector: number; graph: number }, +): Promise { + const kv = mockKV(); + const bm25 = new SearchIndex(); + const vector = provider ? new VectorIndex() : null; + + console.log(` Indexing ${observations.length} observations...`); + const embedStart = performance.now(); + + for (const obs of observations) { + bm25.add(obs); + await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs); + } + + if (provider && vector) { + const batchSize = 32; + for (let i = 0; i < observations.length; i += batchSize) { + const batch = observations.slice(i, i + batchSize); + const texts = batch.map(o => obsToText(o)); + const embeddings = await provider.embedBatch(texts); + for (let j = 0; j < batch.length; j++) { + vector.add(batch[j].id, batch[j].sessionId, embeddings[j]); + } + if ((i + batchSize) % 100 === 0 || i + batchSize >= observations.length) { + process.stdout.write(`\r Embedded ${Math.min(i + batchSize, observations.length)}/${observations.length}`); + } + } + console.log(""); + } + + const embedTime = performance.now() - embedStart; + + const hybrid = new HybridSearch( + bm25, + vector, + provider, + kv as never, + weights.bm25, + weights.vector, + weights.graph, + ); + + console.log(` Running ${queries.length} queries...`); + const results: QueryResult[] = []; + + for (const q of queries) { + const relevant = new Set(q.relevantObsIds); + const start = performance.now(); + const searchResults = await hybrid.search(q.query, 20); + const latency = performance.now() - start; + + const retrieved = searchResults.map(r => r.observation.id); + results.push({ + query: q.query, + category: q.category, + recall_5: recall(retrieved, relevant, 5), + recall_10: recall(retrieved, relevant, 10), + precision_5: precision(retrieved, relevant, 5), + ndcg_10: ndcg(retrieved, relevant, 10), + mrr_val: mrr(retrieved, relevant), + relevant_count: relevant.size, + latency_ms: latency, + }); + } + + let totalReturnedTokens = 0; + for (const q of queries) { + const searchResults = await hybrid.search(q.query, 10); + totalReturnedTokens += searchResults.reduce( + (sum, r) => sum + estimateTokens(JSON.stringify(r.observation)), + 0, + ); + } + const avgReturnedTokens = Math.round(totalReturnedTokens / queries.length); + + return { + name, + results, + embed_time_ms: embedTime, + tokens_per_query: avgReturnedTokens, + }; +} + +async function evalBuiltinGrep( + observations: CompressedObservation[], + queries: LabeledQuery[], +): Promise { + const results: QueryResult[] = []; + + for (const q of queries) { + const relevant = new Set(q.relevantObsIds); + const queryTerms = q.query.toLowerCase().split(/\W+/).filter(w => w.length > 2); + const start = performance.now(); + + const scored: Array<{ id: string; score: number }> = []; + for (const obs of observations) { + const text = [obs.title, obs.narrative, ...obs.concepts, ...obs.facts].join(" ").toLowerCase(); + let score = 0; + for (const term of queryTerms) if (text.includes(term)) score++; + if (score > 0) scored.push({ id: obs.id, score }); + } + scored.sort((a, b) => b.score - a.score); + const latency = performance.now() - start; + + const retrieved = scored.map(s => s.id).slice(0, 20); + results.push({ + query: q.query, + category: q.category, + recall_5: recall(retrieved, relevant, 5), + recall_10: recall(retrieved, relevant, 10), + precision_5: precision(retrieved, relevant, 5), + ndcg_10: ndcg(retrieved, relevant, 10), + mrr_val: mrr(retrieved, relevant), + relevant_count: relevant.size, + latency_ms: latency, + }); + } + + const allTokens = estimateTokens(observations.map(o => + `## ${o.title}\n${o.narrative}\nConcepts: ${o.concepts.join(", ")}` + ).join("\n\n")); + + return { name: "Built-in (grep all)", results, embed_time_ms: 0, tokens_per_query: allTokens }; +} + +function generateReport(systems: SystemResult[], obsCount: number): string { + const lines: string[] = []; + const w = (s: string) => lines.push(s); + + w("# agentmemory v0.6.0 — Real Embeddings Quality Evaluation"); + w(""); + w(`**Date:** ${new Date().toISOString()}`); + w(`**Platform:** ${process.platform} ${process.arch}, Node ${process.version}`); + w(`**Dataset:** ${obsCount} observations, 30 sessions, 20 labeled queries`); + w(`**Embedding model:** Xenova/all-MiniLM-L6-v2 (384d, local, no API key)`); + w(""); + + w("## Head-to-Head: Real Embeddings vs Keyword Search"); + w(""); + w("| System | Recall@5 | Recall@10 | Precision@5 | NDCG@10 | MRR | Avg Latency | Tokens/query |"); + w("|--------|----------|-----------|-------------|---------|-----|-------------|--------------|"); + + for (const s of systems) { + const r = s.results; + w(`| ${s.name} | ${pct(avg(r.map(q => q.recall_5)))} | ${pct(avg(r.map(q => q.recall_10)))} | ${pct(avg(r.map(q => q.precision_5)))} | ${pct(avg(r.map(q => q.ndcg_10)))} | ${pct(avg(r.map(q => q.mrr_val)))} | ${avg(r.map(q => q.latency_ms)).toFixed(2)}ms | ${s.tokens_per_query.toLocaleString()} |`); + } + + w(""); + w("## Improvement from Real Embeddings"); + w(""); + + const bm25Only = systems.find(s => s.name === "BM25-only (stemmed+synonyms)"); + const dual = systems.find(s => s.name.includes("Dual-stream")); + const triple = systems.find(s => s.name.includes("Triple-stream")); + const builtin = systems.find(s => s.name.includes("grep")); + + if (bm25Only && dual) { + const recallDelta = avg(dual.results.map(q => q.recall_10)) - avg(bm25Only.results.map(q => q.recall_10)); + w(`Adding real vector embeddings to BM25 improves recall@10 by **${(recallDelta * 100).toFixed(1)} percentage points**.`); + } + if (builtin && dual) { + const tokenSaving = (1 - dual.tokens_per_query / builtin.tokens_per_query) * 100; + w(`Token savings vs loading everything: **${tokenSaving.toFixed(0)}%** (${dual.tokens_per_query.toLocaleString()} vs ${builtin.tokens_per_query.toLocaleString()} tokens).`); + } + + w(""); + w("## Per-Query: Where Real Embeddings Win"); + w(""); + + if (bm25Only && dual) { + w("Queries where dual-stream (real embeddings) outperforms BM25-only:"); + w(""); + w("| Query | Category | BM25 Recall@10 | +Vector Recall@10 | Delta |"); + w("|-------|----------|---------------|-------------------|-------|"); + + for (let i = 0; i < bm25Only.results.length; i++) { + const bq = bm25Only.results[i]; + const dq = dual.results[i]; + const delta = dq.recall_10 - bq.recall_10; + const marker = delta > 0 ? " **" : delta < 0 ? " *" : ""; + if (Math.abs(delta) > 0.001) { + w(`| ${bq.query.slice(0, 45)}${bq.query.length > 45 ? "..." : ""} | ${bq.category} | ${pct(bq.recall_10)} | ${pct(dq.recall_10)} | ${delta > 0 ? "+" : ""}${(delta * 100).toFixed(1)}pp${marker} |`); + } + } + } + + w(""); + w("## By Category Comparison"); + w(""); + const categories = ["exact", "semantic", "cross-session", "entity"]; + + w("| Category | Built-in grep | BM25 (stemmed) | +Real Vectors | +Graph |"); + w("|----------|--------------|----------------|--------------|--------|"); + + for (const cat of categories) { + const vals = systems.map(s => { + const qs = s.results.filter(q => q.category === cat); + return qs.length ? pct(avg(qs.map(q => q.recall_10))) : "-"; + }); + w(`| ${cat} | ${vals.join(" | ")} |`); + } + + w(""); + w("## Embedding Performance"); + w(""); + w("| System | Embedding Time | Model | Dimensions |"); + w("|--------|---------------|-------|------------|"); + for (const s of systems) { + if (s.embed_time_ms > 100) { + w(`| ${s.name} | ${(s.embed_time_ms / 1000).toFixed(1)}s | Xenova/all-MiniLM-L6-v2 | 384 |`); + } + } + w(""); + w("Embedding is a one-time cost at ingestion. Search is sub-millisecond after indexing."); + + w(""); + w("## Key Findings"); + w(""); + + if (bm25Only && dual) { + const semBm25 = bm25Only.results.filter(q => q.category === "semantic"); + const semDual = dual.results.filter(q => q.category === "semantic"); + const semImprove = avg(semDual.map(q => q.recall_10)) - avg(semBm25.map(q => q.recall_10)); + + w(`1. **Semantic queries improve most**: ${(semImprove * 100).toFixed(1)}pp recall@10 gain from real embeddings`); + w(`2. **"database performance optimization"** — the hardest query — goes from BM25 ${pct(bm25Only.results.find(q => q.query.includes("database perf"))?.recall_10 ?? 0)} to vector-augmented ${pct(dual.results.find(q => q.query.includes("database perf"))?.recall_10 ?? 0)}`); + w(`3. **Entity/exact queries** are already well-served by BM25+stemming — vectors add marginal value`); + w(`4. **Local embeddings (Xenova)** run without API keys — zero cost, zero latency concerns`); + } + + w(""); + w("## Recommendation"); + w(""); + w("Enable local embeddings by default (`EMBEDDING_PROVIDER=local` or install `@xenova/transformers`)."); + w("This gives agentmemory genuine semantic search that built-in agent memories cannot match —"); + w("understanding that \"database performance optimization\" relates to \"N+1 query fix\" and \"eager loading\"."); + w(""); + + w("---"); + w(`*All measurements use Xenova/all-MiniLM-L6-v2 local embeddings (384 dimensions, no API calls).*`); + + return lines.join("\n"); +} + +async function main() { + console.log("=== agentmemory Real Embeddings Benchmark ===\n"); + + console.log("Loading Xenova/all-MiniLM-L6-v2 model (first run downloads ~80MB)..."); + let provider: EmbeddingProvider; + try { + provider = new LocalEmbeddingProvider(); + const testEmbed = await provider.embed("test"); + console.log(`Model loaded. Dimensions: ${testEmbed.length}\n`); + } catch (err) { + console.error("Failed to load Xenova model:", err); + console.error("Install with: npm install @xenova/transformers"); + process.exit(1); + } + + const { observations, queries } = generateDataset(); + console.log(`Dataset: ${observations.length} observations, ${queries.length} queries\n`); + + console.log("1. Built-in (grep all)..."); + const builtinResult = await evalBuiltinGrep(observations, queries); + console.log(` Recall@10: ${pct(avg(builtinResult.results.map(q => q.recall_10)))}\n`); + + console.log("2. BM25-only (stemmed+synonyms)..."); + const bm25Result = await evalSystem( + "BM25-only (stemmed+synonyms)", + observations, queries, null, + { bm25: 1.0, vector: 0, graph: 0 }, + ); + console.log(` Recall@10: ${pct(avg(bm25Result.results.map(q => q.recall_10)))}\n`); + + console.log("3. Dual-stream (BM25 + real Xenova vectors)..."); + const dualResult = await evalSystem( + "Dual-stream (BM25+Xenova)", + observations, queries, provider, + { bm25: 0.4, vector: 0.6, graph: 0 }, + ); + console.log(` Recall@10: ${pct(avg(dualResult.results.map(q => q.recall_10)))}\n`); + + console.log("4. Triple-stream (BM25 + Xenova + Graph)..."); + const tripleResult = await evalSystem( + "Triple-stream (BM25+Xenova+Graph)", + observations, queries, provider, + { bm25: 0.4, vector: 0.6, graph: 0.3 }, + ); + console.log(` Recall@10: ${pct(avg(tripleResult.results.map(q => q.recall_10)))}\n`); + + const report = generateReport( + [builtinResult, bm25Result, dualResult, tripleResult], + observations.length, + ); + + writeFileSync("benchmark/REAL-EMBEDDINGS.md", report); + console.log(report); + console.log(`\nReport written to benchmark/REAL-EMBEDDINGS.md`); +} + +main().catch(console.error); diff --git a/benchmark/results/load-100k-96c0ed0.json b/benchmark/results/load-100k-96c0ed0.json new file mode 100644 index 0000000..726a002 --- /dev/null +++ b/benchmark/results/load-100k-96c0ed0.json @@ -0,0 +1,61 @@ +{ + "schema_version": 1, + "generated_at": "2026-05-13T19:49:26.116Z", + "git_sha": "96c0ed0", + "base_url": "http://localhost:3111", + "seed": 12648430, + "matrix": { + "N": [ + 1000 + ], + "C": [ + 10 + ] + }, + "ops_per_cell": 200, + "cells": [ + { + "endpoint": "POST /agentmemory/remember", + "N": 1000, + "C": 10, + "ops": 200, + "errors": 0, + "wall_ms": 11504.64, + "throughput_per_sec": 17.38, + "p50_ms": 577.435, + "p90_ms": 607.335, + "p99_ms": 675.269, + "min_ms": 64.46, + "max_ms": 683.164 + }, + { + "endpoint": "POST /agentmemory/smart-search", + "N": 1000, + "C": 10, + "ops": 200, + "errors": 0, + "wall_ms": 3264.572, + "throughput_per_sec": 61.26, + "p50_ms": 160.064, + "p90_ms": 185.608, + "p99_ms": 224.354, + "min_ms": 98.498, + "max_ms": 251.317 + }, + { + "endpoint": "GET /agentmemory/memories?latest=true", + "N": 1000, + "C": 10, + "ops": 200, + "errors": 0, + "wall_ms": 8051.764, + "throughput_per_sec": 24.84, + "p50_ms": 395.462, + "p90_ms": 475.714, + "p99_ms": 542.648, + "min_ms": 158.79, + "max_ms": 635.331 + } + ], + "notes": "Single-process load harness. Latency in milliseconds. Throughput is wall-clock ops/sec for the cell (concurrent in-flight = C)." +} diff --git a/benchmark/scale-eval.ts b/benchmark/scale-eval.ts new file mode 100644 index 0000000..43a5a47 --- /dev/null +++ b/benchmark/scale-eval.ts @@ -0,0 +1,398 @@ +import { SearchIndex } from "../src/state/search-index.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import { HybridSearch } from "../src/state/hybrid-search.js"; +import type { CompressedObservation } from "../src/types.js"; +import { generateScaleDataset, generateDataset } from "./dataset.js"; +import { writeFileSync } from "node:fs"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function deterministicEmbedding(text: string, dims = 384): Float32Array { + const arr = new Float32Array(dims); + const words = text.toLowerCase().split(/\W+/).filter(w => w.length > 2); + for (const word of words) { + for (let i = 0; i < word.length; i++) { + const idx = (word.charCodeAt(i) * 31 + i * 17) % dims; + arr[idx] += 1; + const idx2 = (word.charCodeAt(i) * 37 + i * 13 + word.length * 7) % dims; + arr[idx2] += 0.5; + } + } + const norm = Math.sqrt(arr.reduce((s, v) => s + v * v, 0)); + if (norm > 0) for (let i = 0; i < dims; i++) arr[i] /= norm; + return arr; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +interface ScaleResult { + scale: number; + sessions: number; + index_build_ms: number; + index_build_per_doc_ms: number; + bm25_search_ms: number; + hybrid_search_ms: number; + index_size_kb: number; + vector_size_kb: number; + heap_mb: number; + builtin_tokens: number; + builtin_200line_tokens: number; + agentmemory_tokens: number; + token_savings_pct: number; + builtin_unreachable_pct: number; +} + +interface CrossSessionResult { + query: string; + target_session: string; + current_session: string; + sessions_apart: number; + bm25_found: boolean; + bm25_rank: number; + hybrid_found: boolean; + hybrid_rank: number; + builtin_found: boolean; + latency_ms: number; +} + +const SEARCH_QUERIES = [ + "authentication middleware JWT", + "PostgreSQL connection pooling", + "Kubernetes pod crash", + "rate limiting API", + "Playwright E2E tests", + "Docker multi-stage build", + "Redis caching layer", + "CI/CD GitHub Actions", + "Prisma migration drift", + "monitoring Datadog alerts", +]; + +async function benchmarkScale(counts: number[]): Promise { + const results: ScaleResult[] = []; + + for (const count of counts) { + console.log(` Scale: ${count.toLocaleString()} observations...`); + const observations = generateScaleDataset(count); + const sessionCount = new Set(observations.map(o => o.sessionId)).size; + + const heapBefore = process.memoryUsage().heapUsed; + + const buildStart = performance.now(); + const bm25 = new SearchIndex(); + const vector = new VectorIndex(); + const kv = mockKV(); + const dims = 384; + + for (const obs of observations) { + bm25.add(obs); + const text = [obs.title, obs.narrative, ...obs.concepts].join(" "); + vector.add(obs.id, obs.sessionId, deterministicEmbedding(text, dims)); + await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs); + } + const buildMs = performance.now() - buildStart; + + const heapAfter = process.memoryUsage().heapUsed; + + const mockEmbed: any = { + name: "deterministic", dimensions: dims, + embed: async (t: string) => deterministicEmbedding(t, dims), + embedBatch: async (ts: string[]) => ts.map(t => deterministicEmbedding(t, dims)), + }; + const hybrid = new HybridSearch(bm25, vector, mockEmbed, kv as never, 0.4, 0.6, 0); + + let bm25Total = 0; + let hybridTotal = 0; + const iters = 20; + + for (let i = 0; i < iters; i++) { + const q = SEARCH_QUERIES[i % SEARCH_QUERIES.length]; + const s1 = performance.now(); + bm25.search(q, 10); + bm25Total += performance.now() - s1; + + const s2 = performance.now(); + await hybrid.search(q, 10); + hybridTotal += performance.now() - s2; + } + + const bm25Ser = bm25.serialize(); + const vecSer = vector.serialize(); + + const allText = observations.map(o => + `- ${o.title}: ${o.narrative.slice(0, 80)}... [${o.concepts.slice(0, 3).join(", ")}]` + ).join("\n"); + const builtinTokens = estimateTokens(allText); + + const truncatedText = observations.slice(0, 200).map(o => + `- ${o.title}: ${o.narrative.slice(0, 60)}... [${o.concepts.slice(0, 3).join(", ")}]` + ).join("\n"); + const builtin200Tokens = estimateTokens(truncatedText); + + let totalResultTokens = 0; + for (let i = 0; i < iters; i++) { + const q = SEARCH_QUERIES[i % SEARCH_QUERIES.length]; + const results = await hybrid.search(q, 10); + totalResultTokens += estimateTokens(JSON.stringify(results.map(r => r.observation))); + } + const agentmemoryTokens = Math.round(totalResultTokens / iters); + + results.push({ + scale: count, + sessions: sessionCount, + index_build_ms: Math.round(buildMs), + index_build_per_doc_ms: +(buildMs / count).toFixed(3), + bm25_search_ms: +(bm25Total / iters).toFixed(3), + hybrid_search_ms: +(hybridTotal / iters).toFixed(3), + index_size_kb: Math.round(Buffer.byteLength(bm25Ser, "utf-8") / 1024), + vector_size_kb: Math.round(Buffer.byteLength(vecSer, "utf-8") / 1024), + heap_mb: Math.round((heapAfter - heapBefore) / 1024 / 1024), + builtin_tokens: builtinTokens, + builtin_200line_tokens: builtin200Tokens, + agentmemory_tokens: agentmemoryTokens, + token_savings_pct: Math.round((1 - agentmemoryTokens / builtinTokens) * 100), + builtin_unreachable_pct: count <= 200 ? 0 : Math.round((1 - 200 / count) * 100), + }); + } + + return results; +} + +async function benchmarkCrossSession(): Promise { + const { observations } = generateDataset(); + const results: CrossSessionResult[] = []; + + const bm25 = new SearchIndex(); + const kv = mockKV(); + const vector = new VectorIndex(); + const dims = 384; + + for (const obs of observations) { + bm25.add(obs); + const text = [obs.title, obs.narrative, ...obs.concepts].join(" "); + vector.add(obs.id, obs.sessionId, deterministicEmbedding(text, dims)); + await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs); + } + + const mockEmbed: any = { + name: "deterministic", dimensions: dims, + embed: async (t: string) => deterministicEmbedding(t, dims), + embedBatch: async (ts: string[]) => ts.map(t => deterministicEmbedding(t, dims)), + }; + const hybrid = new HybridSearch(bm25, vector, mockEmbed, kv as never, 0.4, 0.6, 0); + + const crossQueries: Array<{ + query: string; + targetConcepts: string[]; + targetSessionRange: [number, number]; + currentSession: number; + }> = [ + { query: "How did we set up OAuth providers?", targetConcepts: ["oauth", "nextauth"], targetSessionRange: [5, 9], currentSession: 29 }, + { query: "What was the N+1 query fix?", targetConcepts: ["n+1", "eager-loading"], targetSessionRange: [10, 14], currentSession: 28 }, + { query: "PostgreSQL full-text search setup", targetConcepts: ["full-text-search", "tsvector"], targetSessionRange: [10, 14], currentSession: 27 }, + { query: "bcrypt password hashing configuration", targetConcepts: ["bcrypt", "password-hashing"], targetSessionRange: [5, 9], currentSession: 25 }, + { query: "Vitest unit testing setup", targetConcepts: ["vitest", "unit-testing"], targetSessionRange: [20, 24], currentSession: 29 }, + { query: "webhook retry exponential backoff", targetConcepts: ["webhooks", "exponential-backoff"], targetSessionRange: [15, 19], currentSession: 29 }, + { query: "ESLint flat config migration", targetConcepts: ["eslint", "linting"], targetSessionRange: [0, 4], currentSession: 29 }, + { query: "Kubernetes HPA autoscaling configuration", targetConcepts: ["hpa", "autoscaling", "kubernetes"], targetSessionRange: [25, 29], currentSession: 29 }, + { query: "Prisma database seed script", targetConcepts: ["seeding", "faker", "prisma"], targetSessionRange: [10, 14], currentSession: 26 }, + { query: "API cursor-based pagination", targetConcepts: ["cursor-based", "pagination"], targetSessionRange: [15, 19], currentSession: 29 }, + { query: "CSRF protection double-submit cookie", targetConcepts: ["csrf", "cookies"], targetSessionRange: [5, 9], currentSession: 29 }, + { query: "blue-green deployment rollback", targetConcepts: ["blue-green", "rollback", "zero-downtime"], targetSessionRange: [25, 29], currentSession: 29 }, + ]; + + for (const cq of crossQueries) { + const targetObs = observations.filter(o => + o.concepts.some(c => cq.targetConcepts.includes(c)) + ); + const targetIds = new Set(targetObs.map(o => o.id)); + + const start = performance.now(); + const bm25Results = bm25.search(cq.query, 20); + const hybridResults = await hybrid.search(cq.query, 20); + const latency = performance.now() - start; + + const bm25Rank = bm25Results.findIndex(r => targetIds.has(r.obsId)); + const hybridRank = hybridResults.findIndex(r => targetIds.has(r.observation.id)); + + const builtinLines = 200; + const visibleObs = observations.slice(0, builtinLines); + const builtinFound = visibleObs.some(o => targetIds.has(o.id)); + + const sessionsApart = cq.currentSession - cq.targetSessionRange[0]; + + results.push({ + query: cq.query, + target_session: `ses_${cq.targetSessionRange[0].toString().padStart(3, "0")}-${cq.targetSessionRange[1].toString().padStart(3, "0")}`, + current_session: `ses_${cq.currentSession.toString().padStart(3, "0")}`, + sessions_apart: sessionsApart, + bm25_found: bm25Rank >= 0, + bm25_rank: bm25Rank >= 0 ? bm25Rank + 1 : -1, + hybrid_found: hybridRank >= 0, + hybrid_rank: hybridRank >= 0 ? hybridRank + 1 : -1, + builtin_found: builtinFound, + latency_ms: latency, + }); + } + + return results; +} + +function generateReport(scale: ScaleResult[], cross: CrossSessionResult[]): string { + const lines: string[] = []; + const w = (s: string) => lines.push(s); + + w("# agentmemory v0.6.0 — Scale & Cross-Session Evaluation"); + w(""); + w(`**Date:** ${new Date().toISOString()}`); + w(`**Platform:** ${process.platform} ${process.arch}, Node ${process.version}`); + w(""); + + w("## 1. Scale: agentmemory vs Built-in Memory"); + w(""); + w("Every built-in agent memory (CLAUDE.md, .cursorrules, Cline's memory-bank) loads ALL memory into context every session. agentmemory searches and returns only relevant results."); + w(""); + w("| Observations | Sessions | Index Build | BM25 Search | Hybrid Search | Heap | Context Tokens (built-in) | Context Tokens (agentmemory) | Savings | Built-in Unreachable |"); + w("|-------------|----------|------------|-------------|---------------|------|--------------------------|-----------------------------|---------|--------------------|"); + + for (const r of scale) { + w(`| ${r.scale.toLocaleString()} | ${r.sessions} | ${r.index_build_ms}ms | ${r.bm25_search_ms}ms | ${r.hybrid_search_ms}ms | ${r.heap_mb}MB | ${r.builtin_tokens.toLocaleString()} | ${r.agentmemory_tokens.toLocaleString()} | ${r.token_savings_pct}% | ${r.builtin_unreachable_pct}% |`); + } + + w(""); + w("### What the numbers mean"); + w(""); + w("**Context Tokens (built-in):** How many tokens Claude Code/Cursor/Cline would consume loading ALL memory into the context window. At 5,000 observations, this is ~250K tokens — exceeding most context windows entirely."); + w(""); + w("**Context Tokens (agentmemory):** How many tokens the top-10 search results consume. Stays constant regardless of corpus size."); + w(""); + w("**Built-in Unreachable:** Percentage of memories that built-in systems CANNOT access because they exceed the 200-line MEMORY.md cap or context window limits. At 1,000 observations, 80% of your project history is invisible."); + w(""); + + w("### Storage Costs"); + w(""); + w("| Observations | BM25 Index | Vector Index (d=384) | Total Storage |"); + w("|-------------|-----------|---------------------|---------------|"); + for (const r of scale) { + const total = r.index_size_kb + r.vector_size_kb; + w(`| ${r.scale.toLocaleString()} | ${r.index_size_kb.toLocaleString()} KB | ${r.vector_size_kb.toLocaleString()} KB | ${(total / 1024).toFixed(1)} MB |`); + } + + w(""); + w("## 2. Cross-Session Retrieval"); + w(""); + w("Can the system find relevant information from past sessions? This is impossible for built-in memory once observations exceed the line/context cap."); + w(""); + w("| Query | Target Session | Gap | BM25 Found | BM25 Rank | Hybrid Found | Hybrid Rank | Built-in Visible |"); + w("|-------|---------------|-----|-----------|-----------|-------------|-------------|-----------------|"); + + for (const r of cross) { + w(`| ${r.query.slice(0, 40)}${r.query.length > 40 ? "..." : ""} | ${r.target_session} | ${r.sessions_apart} | ${r.bm25_found ? "Yes" : "No"} | ${r.bm25_rank > 0 ? `#${r.bm25_rank}` : "-"} | ${r.hybrid_found ? "Yes" : "No"} | ${r.hybrid_rank > 0 ? `#${r.hybrid_rank}` : "-"} | ${r.builtin_found ? "Yes" : "No"} |`); + } + + const bm25Found = cross.filter(r => r.bm25_found).length; + const hybridFound = cross.filter(r => r.hybrid_found).length; + const builtinFound = cross.filter(r => r.builtin_found).length; + + w(""); + w(`**Summary:** agentmemory BM25 found ${bm25Found}/${cross.length} cross-session queries. Hybrid found ${hybridFound}/${cross.length}. Built-in memory (200-line cap) could only reach ${builtinFound}/${cross.length}.`); + + w(""); + w("## 3. The Context Window Problem"); + w(""); + w("```"); + w("Agent context window: ~200K tokens"); + w("System prompt + tools: ~20K tokens"); + w("User conversation: ~30K tokens"); + w("Available for memory: ~150K tokens"); + w(""); + w("At 50 tokens/observation:"); + w(" 200 observations = 10,000 tokens (fits, but 200-line cap hits first)"); + w(" 1,000 observations = 50,000 tokens (33% of available budget)"); + w(" 5,000 observations = 250,000 tokens (EXCEEDS total context window)"); + w(""); + w("agentmemory top-10 results:"); + w(` Any corpus size = ~${scale[0]?.agentmemory_tokens.toLocaleString() || "500"} tokens (0.3% of budget)`); + w("```"); + w(""); + + w("## 4. What Built-in Memory Cannot Do"); + w(""); + w("| Capability | Built-in (CLAUDE.md) | agentmemory |"); + w("|-----------|---------------------|-------------|"); + w("| Semantic search | No (keyword grep only) | BM25 + vector + graph |"); + w("| Scale beyond 200 lines | No (hard cap) | Unlimited |"); + w("| Cross-session recall | Only if in 200-line window | Full corpus search |"); + w("| Cross-agent sharing | No (per-agent files) | MCP + REST API |"); + w("| Multi-agent coordination | No | Leases, signals, actions |"); + w("| Temporal queries | No | Point-in-time graph |"); + w("| Memory lifecycle | No (manual pruning) | Ebbinghaus decay + eviction |"); + w("| Knowledge graph | No | Entity extraction + traversal |"); + w("| Query expansion | No | LLM-generated reformulations |"); + w("| Retention scoring | No | Time-frequency decay model |"); + w("| Real-time dashboard | No (read files manually) | Viewer on :3113 |"); + w("| Concurrent access | No (file lock) | Keyed mutex + KV store |"); + w(""); + + w("## 5. When to Use What"); + w(""); + w("**Use built-in memory (CLAUDE.md) when:**"); + w("- You have < 200 items to remember"); + w("- Single agent, single project"); + w("- Preferences and quick facts only"); + w("- Zero setup is the priority"); + w(""); + w("**Use agentmemory when:**"); + w("- Project history exceeds 200 observations"); + w("- You need to recall specific incidents from weeks ago"); + w("- Multiple agents work on the same codebase"); + w("- You want semantic search (\"how does auth work?\") not just keyword matching"); + w("- You need to track memory quality, decay, and lifecycle"); + w("- You want a shared memory layer across Claude Code, Cursor, Windsurf, etc."); + w(""); + w("Built-in memory is your sticky notes. agentmemory is the searchable database behind them."); + w(""); + + w("---"); + w(`*Scale tests: ${scale.length} corpus sizes. Cross-session tests: ${cross.length} queries targeting specific past sessions.*`); + + return lines.join("\n"); +} + +async function main() { + console.log("=== agentmemory Scale & Cross-Session Evaluation ===\n"); + + console.log("1. Scale benchmarks..."); + const scaleResults = await benchmarkScale([240, 1_000, 5_000, 10_000, 50_000]); + + console.log("\n2. Cross-session retrieval..."); + const crossResults = await benchmarkCrossSession(); + + console.log(""); + const report = generateReport(scaleResults, crossResults); + writeFileSync("benchmark/SCALE.md", report); + console.log(report); + console.log(`\nReport written to benchmark/SCALE.md`); +} + +main().catch(console.error); diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..91aa199 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,100 @@ +# One-click deploy templates + +Stand up agentmemory on managed infrastructure without rolling your own +Docker host. Each template ships a self-contained Dockerfile that pulls +`@agentmemory/agentmemory` from npm at build time and copies the iii +engine binary in from the official `iiidev/iii` image — no pre-built +agentmemory image required. Storage mounts at `/data`; an HMAC secret +is generated by the first-boot entrypoint and persisted to the volume. +The entrypoint overwrites the npm-bundled iii config with a +deploy-tuned one that binds `0.0.0.0` and uses absolute `/data` paths, +then drops privileges from `root` to `node` via `gosu` before +exec'ing the agentmemory CLI. + +| Platform | Pitch | Cost floor | +|----------|-------|------------| +| [fly.io](./fly/README.md) | Single machine with auto-stop. Cheapest idle cost on a managed host; cold-start on first request after sleep. | ~$0.15/month at full idle | +| [Railway](./railway/README.md) | Push from GitHub, volume in the dashboard. Easiest managed dashboard flow. | $5/month (Hobby plan flat fee) | +| [Render](./render/README.md) | Blueprint-driven; persistent disk attaches automatically. Most "set it and forget it." | $7.25/month (Starter web + 1 GB disk) | +| [Coolify](./coolify/README.md) | Self-hosted on your own VPS. Same Docker Compose stack, you own the host and the data. | VPS cost only (Hetzner CX22 ~€3.79/month) | + +## What every template guarantees + +- **Volume mounted at `/data`.** Matches the path the engine has used + since v0.9.10. +- **HMAC secret generated on first boot** via `openssl rand -hex 32`, + written to `/data/.hmac` with `chmod 600`, and printed to stdout + exactly once so the operator can capture it from the deploy logs. + Subsequent boots load the secret from the file. The secret is never + committed to a config file or set as a platform env var. +- **Only port 3111 is exposed publicly.** The viewer on port 3113 + stays bound to the container's localhost. Reach it via SSH tunnel + (see each platform's README). +- **TLS upstream of the container.** Every managed platform terminates + TLS at its edge proxy; the templates publish a single internal port + (`3111`) to that proxy, never to the host. Integration plugins + configured with `AGENTMEMORY_REQUIRE_HTTPS=1` will refuse to send the + bearer over plaintext HTTP to a non-loopback host, so a + misconfigured TLS layer fails loud instead of silently leaking the + secret. + +## Pick a platform + +- Pick **fly.io** if you want the lowest idle cost and don't mind a + cold-start latency hit on the first request after sleep. +- Pick **Railway** if you want a clicky dashboard flow and a flat + monthly bill. +- Pick **Render** if you want the most "set it and forget it" + Blueprint flow with automatic disk snapshots on paid plans. +- Pick **Coolify** if you already run a VPS and want a self-hosted + control plane — same Docker Compose stack, no third-party host has + your memories. + +All four give you the same agentmemory API at the same port (3111) +with the same auth model. Migrating between them later is a `tar` of +`/data` and a re-import — see each platform's README for the exact +commands. + +## Optional: LLM + embedding provider keys + +Every template runs out of the box without any LLM or embedding key — +search falls back to BM25-only mode and synthetic (zero-LLM) +compression keeps memories indexable. To unlock LLM-powered +compression and hybrid (BM25 + vector) recall, add one of the +following to your platform's environment variables (Fly: +`flyctl secrets set`; Railway / Render / Coolify: dashboard +*Variables / Environment* tab): + +| Variable | Purpose | +|---------------------------|----------------------------------------------------------| +| `ANTHROPIC_API_KEY` | LLM-backed compression + summarization | +| `GEMINI_API_KEY` | LLM provider alternative | +| `OPENROUTER_API_KEY` | LLM provider alternative | +| `OPENAI_API_KEY` | Embedding provider (text-embedding-3-small by default) | +| `VOYAGE_API_KEY` | Embedding provider alternative | +| `AGENTMEMORY_AUTO_COMPRESS=true` | Run LLM compression on every observation batch | +| `AGENTMEMORY_INJECT_CONTEXT=true` | Inject recalled memories back into agent prompts | + +The defaults are intentionally conservative: provider keys default to +absent (no third-party calls), `AGENTMEMORY_AUTO_COMPRESS` is off, +and `AGENTMEMORY_INJECT_CONTEXT` is off. Opt in only after you've +confirmed your provider quota can absorb the workload. + +## Cold-start budget + +Measured against fly.io's `iad` region with a 1 GB volume: + +``` +machine image prepared : 5.1 s +volume mount + format : 2.5 s +firecracker boot : 1.0 s +entrypoint + chown : 0.5 s +iii-engine ready : 3.0 s +agentmemory worker reg : 2.0 s +───────────────────────────────── +healthcheck passes : ~9-10 s +``` + +Every template's health-check `grace_period` (or compose +`start_period`) is set to 30 s for a 3x safety margin. Tune lower +once you've measured your own platform's image-pull characteristics. diff --git a/deploy/coolify/Dockerfile b/deploy/coolify/Dockerfile new file mode 100644 index 0000000..48c8d59 --- /dev/null +++ b/deploy/coolify/Dockerfile @@ -0,0 +1,32 @@ +ARG III_VERSION=0.11.2 + +FROM iiidev/iii:${III_VERSION} AS iii-image + +FROM node:22-slim + +ARG AGENTMEMORY_VERSION=0.9.27 +ARG III_VERSION=0.11.2 +ARG III_SDK_VERSION=0.11.2 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates tini gosu curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=iii-image /app/iii /usr/local/bin/iii + +WORKDIR /opt/agentmemory +RUN printf '{"name":"agentmemory-deploy","version":"1.0.0","private":true,"overrides":{"iii-sdk":"%s"}}\n' "${III_SDK_VERSION}" > package.json \ + && npm install "@agentmemory/agentmemory@${AGENTMEMORY_VERSION}" --omit=optional --no-fund --no-audit \ + && ln -s /opt/agentmemory/node_modules/.bin/agentmemory /usr/local/bin/agentmemory + +ENV AGENTMEMORY_III_VERSION=${III_VERSION} \ + TINI_SUBREAPER=1 + +COPY --chmod=0755 entrypoint.sh /usr/local/bin/agentmemory-entrypoint.sh + +EXPOSE 3111 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsS http://127.0.0.1:3111/agentmemory/livez || exit 1 + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/agentmemory-entrypoint.sh"] diff --git a/deploy/coolify/README.md b/deploy/coolify/README.md new file mode 100644 index 0000000..feec475 --- /dev/null +++ b/deploy/coolify/README.md @@ -0,0 +1,132 @@ +# Deploy agentmemory on Coolify + +[Coolify](https://coolify.io/self-hosted) is an open-source, self-hosted +Heroku/Render alternative that you run on your own VPS. This template +deploys agentmemory as a Coolify *Application* backed by a Docker +Compose stack — Coolify handles TLS termination, persistent volume +provisioning, log aggregation, and the deploy webhook for you. + +## What you get + +- A public HTTPS endpoint serving the agentmemory REST API behind + Coolify's built-in Traefik/Caddy proxy. The container port (`3111`) + is exposed to the proxy network only — never bound to the host — so + TLS termination and domain routing stay under proxy control. +- A persistent Docker volume backing `/data` for memories, BM25 index, + and stream backlog. Coolify auto-prefixes the volume name with the + application's UUID so the data survives redeploys. +- An HTTP health-check at `/agentmemory/livez` declared in the + Dockerfile (`HEALTHCHECK` directive). Coolify reuses it for + rolling-deploy decisions. + +## One-time setup + +1. **Open your Coolify dashboard** and click **+ New → Application**. +2. **Source**: pick *Public Repository*. Paste: + ``` + https://github.com/rohitg00/agentmemory + ``` + Branch: `main`. +3. **Build Pack**: select *Docker Compose*. +4. **Base Directory**: `deploy/coolify` +5. **Compose Path**: `docker-compose.yml` +6. Click **Save**, then on the application settings screen set a + **Domain** in the form `https://:3111` (the `:3111` + suffix tells Coolify's proxy which container port to forward to; + it still serves over 443/80 publicly). +7. Click **Deploy**. + +That's it. Coolify clones the repo, builds the Dockerfile under +`deploy/coolify/`, provisions the `agentmemory-data` named volume on +the host, attaches Traefik (or Caddy) for the public domain, and starts +the service. The container is reachable only through the proxy — there +is no published host port. + +## Capture the HMAC secret + +Once the deploy logs show the service is up, open the application's +**Logs** tab in Coolify and search for `AGENTMEMORY_SECRET=`. You will +see exactly one line of the form `AGENTMEMORY_SECRET=<64 hex chars>`. +Copy it into your client environment (`~/.bashrc`, Claude Desktop +config, etc.). The secret is never printed again on subsequent boots. + +## Verify the deployment + +```bash +curl "https:///agentmemory/livez" +# {"status":"ok"} +``` + +For an authenticated call, your client must send +`Authorization: Bearer `. + +## Viewer access (port 3113 stays internal) + +The viewer port is not exposed by the compose file on purpose — it +holds the unauthenticated admin surface in older releases and the +proxied surface in current ones, neither of which belongs on the open +internet. Two paths to reach it: + +**Option A — SSH tunnel from the Coolify host.** Coolify gives you SSH +access to the underlying VPS. From your laptop: + +```bash +ssh -L 3113:127.0.0.1:3113 @ +# inside the SSH session, find the container: +docker ps --filter name=agentmemory --format "{{.Names}}" +# tunnel into the container's port from the host: +docker exec -it sh -c "curl http://localhost:3113" +``` + +Cleaner version: bind the container's 3113 to the host's loopback by +adding `- "127.0.0.1:3113:3113"` to the `ports:` block in +`docker-compose.yml`, redeploy, then `ssh -L 3113:127.0.0.1:3113 +@` is enough. + +**Option B — expose 3113 as a second Coolify domain protected by HTTP +basic auth.** Coolify's per-service routing supports adding a second +public endpoint with basic-auth middleware. Useful if you want to +share the viewer with a teammate without giving them SSH. + +## Rotate the HMAC secret + +```bash +ssh @ +docker exec -it sh -c "rm /data/.hmac" +exit +``` + +Then click **Redeploy** in the Coolify dashboard. The next boot prints +a fresh secret to the logs. + +## Back up `/data` + +Coolify exposes the named volume on the host filesystem under +`/var/lib/docker/volumes/_agentmemory-data/_data`. Back it +up with your existing host-level snapshot tooling (Restic, Borg, +`rsync`, BTRFS snapshots, etc.) or via Coolify's built-in *Backups* +feature for Docker volumes. + +## Cost floor and resources + +- **Hardware**: the agentmemory container idles at ~150 MB RSS, climbs + to ~400 MB under steady traffic. The bundled iii engine adds another + ~80 MB. A 1 vCPU / 1 GB VPS is comfortably enough for a personal + install. +- **VPS providers commonly paired with Coolify**: Hetzner CX22 + (~€3.79/month), DigitalOcean Basic Droplet ($6/month), Vultr Cloud + Compute ($6/month). Coolify itself is free. +- **Volume storage**: tied to whatever block storage the VPS provides; + typically pennies per GB-month. + +## Known caveats + +- The Dockerfile builds on the Coolify host on every deploy. First + deploy takes ~2 minutes; cached layers shrink subsequent rebuilds to + under 30 seconds. Pin `AGENTMEMORY_VERSION` and `III_VERSION` in + `docker-compose.yml`'s `build.args` block to lock a specific release. +- Coolify's *Persistent Storage* tab will show `agentmemory-data` as a + managed volume — do not delete it from the dashboard if you want + your memories to survive a redeploy. +- arm64 hosts work — the iii binary selection in the Dockerfile uses + `uname -m` and downloads the matching tarball. diff --git a/deploy/coolify/docker-compose.yml b/deploy/coolify/docker-compose.yml new file mode 100644 index 0000000..73d5366 --- /dev/null +++ b/deploy/coolify/docker-compose.yml @@ -0,0 +1,30 @@ +services: + agentmemory: + build: + context: . + dockerfile: Dockerfile + args: + AGENTMEMORY_VERSION: "0.9.27" + III_VERSION: "0.11.2" + III_SDK_VERSION: "0.11.2" + restart: unless-stopped + environment: + SERVICE_FQDN_AGENTMEMORY_3111: ${SERVICE_FQDN_AGENTMEMORY_3111} + expose: + - "3111" + volumes: + - agentmemory-data:/data + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3111/agentmemory/livez || exit 1"] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + agentmemory-data: diff --git a/deploy/coolify/entrypoint.sh b/deploy/coolify/entrypoint.sh new file mode 100755 index 0000000..ffdd633 --- /dev/null +++ b/deploy/coolify/entrypoint.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# agentmemory first-boot entrypoint. +# +# Runs as root so it can: +# 1. Overwrite the npm-bundled iii-config.yaml (which binds 127.0.0.1 +# and uses relative ./data paths) with a deploy-tuned version that +# binds 0.0.0.0 and uses absolute /data paths. +# 2. chown the platform-mounted /data volume to the runtime user +# (managed platforms mount volumes root-owned 755 by default). +# 3. Generate the HMAC secret on first boot and persist it to +# /data/.hmac (chmod 600) so the secret survives restarts. +# +# Then it execs the agentmemory CLI under gosu as the unprivileged +# `node` user. + +set -eu + +DATA_DIR="${AGENTMEMORY_DATA_DIR:-/data}" +HMAC_FILE="${AGENTMEMORY_HMAC_FILE:-/data/.hmac}" +RUN_AS="node:node" +III_CONFIG="/opt/agentmemory/node_modules/@agentmemory/agentmemory/dist/iii-config.yaml" + +mkdir -p "$DATA_DIR" +chown -R "$RUN_AS" "$DATA_DIR" + +cat > "$III_CONFIG" <<'EOF' +workers: + - name: iii-http + config: + port: 3111 + host: 0.0.0.0 + default_timeout: 180000 + cors: + allowed_origins: + - "http://localhost:3111" + - "http://localhost:3113" + - "http://127.0.0.1:3111" + - "http://127.0.0.1:3113" + allowed_methods: [GET, POST, PUT, DELETE, OPTIONS] + - name: iii-state + config: + adapter: + name: kv + config: + store_method: file_based + file_path: /data/state_store.db + - name: iii-queue + config: + adapter: + name: builtin + - name: iii-pubsub + config: + adapter: + name: local + - name: iii-cron + config: + adapter: + name: kv + - name: iii-stream + config: + port: 3112 + host: 0.0.0.0 + adapter: + name: kv + config: + store_method: file_based + file_path: /data/stream_store + - name: iii-observability + config: + enabled: true + service_name: agentmemory + exporter: memory + sampling_ratio: 1.0 + metrics_enabled: true + logs_enabled: true + logs_console_output: true +EOF +chown "$RUN_AS" "$III_CONFIG" + +if [ ! -s "$HMAC_FILE" ]; then + SECRET="$(openssl rand -hex 32)" + umask 077 + printf '%s\n' "$SECRET" > "$HMAC_FILE" + chmod 600 "$HMAC_FILE" + chown "$RUN_AS" "$HMAC_FILE" + echo "================================================================" + echo "agentmemory: generated HMAC secret on first boot" + echo "AGENTMEMORY_SECRET=$SECRET" + echo "Copy this value now. It will not be printed again." + echo "Stored at: $HMAC_FILE (chmod 600)" + echo "To rotate: delete $HMAC_FILE on the persistent volume and restart." + echo "================================================================" +fi + +AGENTMEMORY_SECRET="$(cat "$HMAC_FILE")" +export AGENTMEMORY_SECRET + +exec gosu "$RUN_AS" agentmemory "$@" diff --git a/deploy/fly/Dockerfile b/deploy/fly/Dockerfile new file mode 100644 index 0000000..3769815 --- /dev/null +++ b/deploy/fly/Dockerfile @@ -0,0 +1,35 @@ +ARG III_VERSION=0.11.2 + +FROM iiidev/iii:${III_VERSION} AS iii-image + +FROM node:22-slim + +ARG AGENTMEMORY_VERSION=0.9.27 +ARG III_VERSION=0.11.2 +ARG III_SDK_VERSION=0.11.2 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates tini gosu curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=iii-image /app/iii /usr/local/bin/iii + +# Install agentmemory into a dedicated prefix so the local package.json's +# `overrides` field pins iii-sdk down to match the engine (agentmemory's +# caret range `^0.11.2` otherwise resolves to 0.11.6, the version that +# requires the new sandbox-everything worker model the agentmemory CLI +# is not refactored for yet). `npm install -g` ignores overrides, hence +# the local prefix. +WORKDIR /opt/agentmemory +RUN printf '{"name":"agentmemory-deploy","version":"1.0.0","private":true,"overrides":{"iii-sdk":"%s"}}\n' "${III_SDK_VERSION}" > package.json \ + && npm install "@agentmemory/agentmemory@${AGENTMEMORY_VERSION}" --omit=optional --no-fund --no-audit \ + && ln -s /opt/agentmemory/node_modules/.bin/agentmemory /usr/local/bin/agentmemory + +ENV AGENTMEMORY_III_VERSION=${III_VERSION} \ + TINI_SUBREAPER=1 + +COPY --chmod=0755 entrypoint.sh /usr/local/bin/agentmemory-entrypoint.sh + +EXPOSE 3111 + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/agentmemory-entrypoint.sh"] diff --git a/deploy/fly/README.md b/deploy/fly/README.md new file mode 100644 index 0000000..3b7b4e9 --- /dev/null +++ b/deploy/fly/README.md @@ -0,0 +1,166 @@ +# Deploy agentmemory on fly.io + +This template runs agentmemory on a single fly.io machine with a 1 GB +persistent volume mounted at `/data`. The HMAC secret is generated on +first boot and persisted to the volume — you capture it from the deploy +logs exactly once. + +## What you get + +- A public HTTPS endpoint serving the agentmemory REST API on port 3111 +- A 1 GB Fly Volume at `/data` for memories, BM25 index, and stream backlog +- `auto_stop_machines = "stop"` and `min_machines_running = 0` — the + machine sleeps when idle, so cost floor approaches $0 for low traffic +- HTTP healthcheck at `/agentmemory/livez` every 30 s +- The HMAC bearer secret is generated on first boot inside the + container and persisted to `/data/.hmac` (chmod 600); the operator + copies it from the deploy logs once. + +## One-time setup + +Pick a unique Fly app name first — `agentmemory` itself is likely taken. +Every command below references `$APP`, so set it once and the rest of the +flow stays consistent: + +```bash +# 1. Install flyctl: https://fly.io/docs/flyctl/install/ +# 2. Pick your unique app name (and matching volume name): +export APP="agentmemory-$(whoami)" # or any other globally-unique name +export VOLUME="${APP//-/_}_data" # Fly volume names can't contain '-' + +# 3. From this directory: +fly launch --copy-config --no-deploy --name "$APP" + +# 4. Create the volume in the same region as the app: +fly volumes create "$VOLUME" --region iad --size 1 + +# 5. Deploy: +fly deploy --app "$APP" +``` + +If `fly launch` reports the name is taken, pick another value for `$APP`, +re-export, and re-run. + +## Capture the HMAC secret + +Right after the first deploy succeeds: + +```bash +fly logs --app "$APP" | grep -A1 AGENTMEMORY_SECRET= +``` + +You will see exactly one line of the form `AGENTMEMORY_SECRET=<64 hex chars>`. +Copy it into your client environment (`~/.bashrc`, Claude Desktop config, +the viewer unlock prompt, etc.). The secret is never printed again on +subsequent boots. + +If the first-boot log line is no longer available, read the persisted +secret from the mounted volume: + +```bash +fly ssh console --app "$APP" -C "sh -lc 'cat /data/.hmac'" +``` + +## Verify the deployment + +```bash +curl "https://$APP.fly.dev/agentmemory/livez" +# {"status":"ok"} +``` + +For an authenticated call, your client must send `Authorization: Bearer `. + +## Viewer access (port 3113 stays internal) + +The viewer port is intentionally not exposed publicly. Tunnel to it: + +```bash +fly proxy 3113:3113 --app "$APP" +# then open http://localhost:3113 +``` + +`fly proxy` opens an mTLS WireGuard channel to the machine, so the +viewer's bearer token still has to ride a loopback connection on your +laptop — the v0.9.12 plaintext-bearer guard stays satisfied. + +The entrypoint sets `AGENTMEMORY_VIEWER_HOST=::` **only when it detects +Fly's runtime variables** (`FLY_APP_NAME` / `FLY_ALLOC_ID`). That makes +the viewer listen on the machine's `fly-local-6pn` WireGuard interface +as well as loopback so `fly proxy` can reach it. The same branch +pre-seeds `VIEWER_ALLOWED_HOSTS=localhost:3113,127.0.0.1:3113,[::1]:3113`, +which are the Host headers `fly proxy 3113:3113` actually emits on +your laptop. + +When `AGENTMEMORY_VIEWER_HOST` is non-loopback the viewer enforces two +extra guards: it refuses to start unless `VIEWER_ALLOWED_HOSTS` is +explicitly set, and every request to `/agentmemory/*` must present +`Authorization: Bearer $AGENTMEMORY_SECRET`. Static HTML and the +favicon are still served unauthenticated. If a proxied viewer request +gets a 401, the browser UI prompts for `AGENTMEMORY_SECRET` and stores +it in session storage so subsequent viewer API calls include the bearer. +Use the value printed in the first-boot logs or read `/data/.hmac` +inside the machine. + +> **Security warning.** Setting `AGENTMEMORY_VIEWER_HOST=0.0.0.0` or +> `::` turns the viewer into a network-reachable proxy that signs every +> upstream call with `AGENTMEMORY_SECRET`. Never enable that outside a +> network you trust (Fly's WireGuard mesh in this template), and never +> set it in a plain `docker run -p 3113:3113 …` on a shared host — the +> entrypoint deliberately skips the override when Fly env vars are +> absent so a plain Docker pull stays loopback-only. + +## Rotate the HMAC secret + +```bash +fly ssh console --app "$APP" +rm /data/.hmac +exit +fly machine restart +fly logs --app "$APP" | grep AGENTMEMORY_SECRET= +``` + +Update every client with the new secret. Old tokens stop working +immediately. + +## Back up `/data` + +```bash +fly ssh console --app "$APP" -C "tar czf - /data" > "$APP-$(date +%Y%m%d).tar.gz" +``` + +To restore on a fresh machine: + +```bash +cat "$APP-YYYYMMDD.tar.gz" | fly ssh console --app "$APP" -C "tar xzf - -C /" +fly machine restart +``` + +## Cost floor and egress + +- Idle (machine stopped): the volume costs ~$0.15/GB/month. A 1 GB + volume is roughly $0.15/month. +- Active (machine running on `shared-cpu-1x` with 512 MB): about + $1.94/month if it ran 24/7; in practice `auto_stop_machines` keeps + that well under $1. +- Outbound bandwidth: 100 GB/month free on the Hobby plan, then $0.02/GB + in North America / Europe. + +See for the up-to-date rate card. + +## Known caveats + +- The volume lives in one region. To survive a region outage, create a + second volume in another region and update `primary_region` after the + failover, or take snapshots with `fly volumes snapshots create`. +- The Dockerfile builds in the Fly Builder on every deploy — first + build is ~30 seconds; cached layers shrink rebuilds to under 10 + seconds. Image is ~114 MB. +- First deploy lands on a **shared IPv4 + dedicated IPv6** by default + (free). If you need a dedicated IPv4 for legacy clients without SNI, + run `fly ips allocate-v4 --app "$APP"` — costs $2/month. +- Cold-start (from machine launch to passing `/agentmemory/livez`) is + ~9 seconds measured. `grace_period = "30s"` on the health check + gives a 3x safety margin. +- Bump `AGENTMEMORY_VERSION` or `III_VERSION` in the Dockerfile to + upgrade. `fly deploy --build-arg AGENTMEMORY_VERSION=` also works + for a one-off without editing the file. diff --git a/deploy/fly/entrypoint.sh b/deploy/fly/entrypoint.sh new file mode 100755 index 0000000..5fd4cc2 --- /dev/null +++ b/deploy/fly/entrypoint.sh @@ -0,0 +1,112 @@ +#!/bin/sh +# agentmemory first-boot entrypoint. +# +# Runs as root so it can: +# 1. Overwrite the npm-bundled iii-config.yaml (which binds 127.0.0.1 +# and uses relative ./data paths) with a deploy-tuned version that +# binds 0.0.0.0 and uses absolute /data paths. +# 2. chown the platform-mounted /data volume to the runtime user +# (managed platforms mount volumes root-owned 755 by default). +# 3. Generate the HMAC secret on first boot and persist it to +# /data/.hmac (chmod 600) so the secret survives restarts. +# +# Then it execs the agentmemory CLI under gosu as the unprivileged +# `node` user. + +set -eu + +DATA_DIR="${AGENTMEMORY_DATA_DIR:-/data}" +HMAC_FILE="${AGENTMEMORY_HMAC_FILE:-/data/.hmac}" +RUN_AS="node:node" +III_CONFIG="/opt/agentmemory/node_modules/@agentmemory/agentmemory/dist/iii-config.yaml" + +mkdir -p "$DATA_DIR" +chown -R "$RUN_AS" "$DATA_DIR" + +cat > "$III_CONFIG" <<'EOF' +workers: + - name: iii-http + config: + port: 3111 + host: 0.0.0.0 + default_timeout: 180000 + cors: + allowed_origins: + - "http://localhost:3111" + - "http://localhost:3113" + - "http://127.0.0.1:3111" + - "http://127.0.0.1:3113" + allowed_methods: [GET, POST, PUT, DELETE, OPTIONS] + - name: iii-state + config: + adapter: + name: kv + config: + store_method: file_based + file_path: /data/state_store.db + - name: iii-queue + config: + adapter: + name: builtin + - name: iii-pubsub + config: + adapter: + name: local + - name: iii-cron + config: + adapter: + name: kv + - name: iii-stream + config: + port: 3112 + host: 0.0.0.0 + adapter: + name: kv + config: + store_method: file_based + file_path: /data/stream_store + - name: iii-observability + config: + enabled: true + service_name: agentmemory + exporter: memory + sampling_ratio: 1.0 + metrics_enabled: true + logs_enabled: true + logs_console_output: true +EOF +chown "$RUN_AS" "$III_CONFIG" + +if [ ! -s "$HMAC_FILE" ]; then + SECRET="$(openssl rand -hex 32)" + umask 077 + printf '%s\n' "$SECRET" > "$HMAC_FILE" + chmod 600 "$HMAC_FILE" + chown "$RUN_AS" "$HMAC_FILE" + echo "================================================================" + echo "agentmemory: generated HMAC secret on first boot" + echo "AGENTMEMORY_SECRET=$SECRET" + echo "Copy this value now. It will not be printed again." + echo "Stored at: $HMAC_FILE (chmod 600)" + echo "To rotate: delete $HMAC_FILE on the persistent volume and restart." + echo "================================================================" +fi + +AGENTMEMORY_SECRET="$(cat "$HMAC_FILE")" +export AGENTMEMORY_SECRET + +# The viewer's default 127.0.0.1 bind is unreachable through fly proxy, +# which enters the machine via fly-local-6pn (IPv6). Opt into a +# non-loopback bind ONLY when we're actually inside Fly (detected via +# Fly's runtime variables). A plain `docker run` of this image will not +# see these variables and will keep the safe-by-default loopback bind, +# so it can't silently expose the viewer's bearer-authorized proxy to +# the LAN. VIEWER_ALLOWED_HOSTS is preseeded to the Host headers that +# `fly proxy 3113:3113` actually produces on the operator's laptop. +if [ -n "${FLY_APP_NAME:-}" ] || [ -n "${FLY_ALLOC_ID:-}" ]; then + : "${AGENTMEMORY_VIEWER_HOST:=::}" + : "${VIEWER_ALLOWED_HOSTS:=localhost:3113,127.0.0.1:3113,[::1]:3113}" + export AGENTMEMORY_VIEWER_HOST VIEWER_ALLOWED_HOSTS +fi + +exec gosu "$RUN_AS" agentmemory "$@" diff --git a/deploy/fly/fly.toml b/deploy/fly/fly.toml new file mode 100644 index 0000000..03f58bc --- /dev/null +++ b/deploy/fly/fly.toml @@ -0,0 +1,44 @@ +# fly.io deployment for agentmemory. +# +# The HMAC secret is generated by entrypoint.sh on first boot and persisted +# to the mounted volume at /data/.hmac. Operator copies it once from +# `fly logs` then never sees it again. To rotate: `fly ssh console` and +# `rm /data/.hmac`, then `fly machine restart`. +# +# Only port 3111 (REST API) is exposed publicly. Viewer 3113 stays bound +# to localhost inside the machine; reach it via `fly proxy 3113:3113`. + +app = "agentmemory" +primary_region = "iad" + +[build] + dockerfile = "Dockerfile" + +[[mounts]] + source = "agentmemory_data" + destination = "/data" + initial_size = "1gb" + +[http_service] + internal_port = 3111 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + soft_limit = 200 + hard_limit = 250 + + [[http_service.checks]] + interval = "30s" + timeout = "5s" + grace_period = "30s" + method = "GET" + path = "/agentmemory/livez" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" diff --git a/deploy/railway/Dockerfile b/deploy/railway/Dockerfile new file mode 100644 index 0000000..3769815 --- /dev/null +++ b/deploy/railway/Dockerfile @@ -0,0 +1,35 @@ +ARG III_VERSION=0.11.2 + +FROM iiidev/iii:${III_VERSION} AS iii-image + +FROM node:22-slim + +ARG AGENTMEMORY_VERSION=0.9.27 +ARG III_VERSION=0.11.2 +ARG III_SDK_VERSION=0.11.2 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates tini gosu curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=iii-image /app/iii /usr/local/bin/iii + +# Install agentmemory into a dedicated prefix so the local package.json's +# `overrides` field pins iii-sdk down to match the engine (agentmemory's +# caret range `^0.11.2` otherwise resolves to 0.11.6, the version that +# requires the new sandbox-everything worker model the agentmemory CLI +# is not refactored for yet). `npm install -g` ignores overrides, hence +# the local prefix. +WORKDIR /opt/agentmemory +RUN printf '{"name":"agentmemory-deploy","version":"1.0.0","private":true,"overrides":{"iii-sdk":"%s"}}\n' "${III_SDK_VERSION}" > package.json \ + && npm install "@agentmemory/agentmemory@${AGENTMEMORY_VERSION}" --omit=optional --no-fund --no-audit \ + && ln -s /opt/agentmemory/node_modules/.bin/agentmemory /usr/local/bin/agentmemory + +ENV AGENTMEMORY_III_VERSION=${III_VERSION} \ + TINI_SUBREAPER=1 + +COPY --chmod=0755 entrypoint.sh /usr/local/bin/agentmemory-entrypoint.sh + +EXPOSE 3111 + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/agentmemory-entrypoint.sh"] diff --git a/deploy/railway/README.md b/deploy/railway/README.md new file mode 100644 index 0000000..9aad4fb --- /dev/null +++ b/deploy/railway/README.md @@ -0,0 +1,136 @@ +# Deploy agentmemory on Railway + +This template runs agentmemory on a single Railway service with a +persistent volume mounted at `/data`. The HMAC secret is generated on +first boot and persisted to the volume — you read it once from the +deploy logs and copy it into your client. + +## What you get + +- A public HTTPS endpoint serving the agentmemory REST API on port 3111 +- A persistent Railway Volume at `/data` for memories, BM25 index, and + stream backlog +- Railway healthcheck against `/agentmemory/livez` +- The HMAC bearer secret is generated on first boot inside the + container and persisted to `/data/.hmac` (chmod 600); the operator + copies it from the deploy logs once. +- The deploy uses `requiredMountPath: /data` so Railway refuses to + start the service if no volume is attached at that path — first + deploy must create the volume from the dashboard. + +## Deploy via Railway dashboard + +1. Click **Deploy from GitHub** in the Railway dashboard and pick the + `rohitg00/agentmemory` repo. +2. Set the **Config-as-Code Path** under the service Settings to + `deploy/railway/railway.json`. Railway picks up the Dockerfile path + from there. +3. Open the service's **Volumes** tab and add a volume mounted at + `/data` (Railway volumes are configured in the dashboard or via + `railway volume add`, not in `railway.json`). +4. Click **Deploy**. + +## Deploy via Railway CLI + +```bash +# Install: https://docs.railway.com/guides/cli +railway login +railway init # link a new project +railway up --service agentmemory # builds + deploys +railway volume add --service agentmemory --mount /data # attach persistent volume +railway redeploy # restart with the volume +``` + +## Capture the HMAC secret + +After the first deploy succeeds, open the service's **Deploy Logs**: + +```bash +railway logs --service agentmemory | grep AGENTMEMORY_SECRET= +``` + +You will see exactly one line of the form `AGENTMEMORY_SECRET=<64 hex chars>`. +Copy it into your client environment. The secret is never printed again +on subsequent boots. + +## Verify the deployment + +```bash +curl https://.up.railway.app/agentmemory/livez +# {"status":"ok"} +``` + +For an authenticated call, your client must send `Authorization: Bearer `. + +## Viewer access (port 3113 stays internal) + +Railway only exposes the single public port from your service's +`PORT` env var (which we map to 3111). The viewer stays bound to +localhost inside the container. `railway ssh` is an interactive shell +only — it does not support `-L`-style port forwarding, so reach the +viewer with one of the following. + +**Quick in-container check:** + +```bash +railway ssh --service agentmemory +# inside the container: +curl http://localhost:3113 +``` + +**Browser session — option A (TCP Proxy, recommended):** in the Railway +dashboard, open the service's *Settings → Networking* tab and add a +**TCP Proxy** for container port `3113`. Railway returns a public +host/port pair you can hit directly from your browser. Pair it with the +HMAC bearer-auth header so the viewer is not anonymously reachable. + +**Browser session — option B (in-container sshd):** add an `openssh-server` +process to the image and start it from `entrypoint.sh` on a fixed port, +expose that port through a second Railway TCP Proxy, then use a native +`ssh -L 3113:localhost:3113 -p ` from your laptop. +This is the heavier path; option A is what most users will want. + +## Rotate the HMAC secret + +```bash +railway ssh --service agentmemory +rm /data/.hmac +exit +railway redeploy --service agentmemory +railway logs --service agentmemory | grep AGENTMEMORY_SECRET= +``` + +Update every client with the new secret. Old tokens stop working +immediately. + +## Back up `/data` + +```bash +railway ssh --service agentmemory -- "tar czf - /data" > agentmemory-$(date +%Y%m%d).tar.gz +``` + +To restore on a fresh volume: + +```bash +cat agentmemory-YYYYMMDD.tar.gz | railway ssh --service agentmemory -- "tar xzf - -C /" +railway redeploy --service agentmemory +``` + +## Cost floor and egress + +- Hobby plan: $5/month flat, includes $5 of usage. +- agentmemory at idle plus a 1 GB volume typically uses $3–$6 of usage + per month on the smallest instance, so most users stay near the $5 + floor. +- Egress: $0.10/GB after the bundled allowance. + +See for the current rate card. + +## Known caveats + +- Railway volumes do not auto-snapshot. Take your own backups (above) + or use the dashboard's manual snapshot feature. +- The Dockerfile builds on Railway's builder on every deploy. First + deploy is ~2 minutes; cached layers make subsequent rebuilds quick. + Pin `AGENTMEMORY_VERSION` / `III_VERSION` build args in the + service's *Variables* tab to lock a specific release. diff --git a/deploy/railway/entrypoint.sh b/deploy/railway/entrypoint.sh new file mode 100755 index 0000000..ffdd633 --- /dev/null +++ b/deploy/railway/entrypoint.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# agentmemory first-boot entrypoint. +# +# Runs as root so it can: +# 1. Overwrite the npm-bundled iii-config.yaml (which binds 127.0.0.1 +# and uses relative ./data paths) with a deploy-tuned version that +# binds 0.0.0.0 and uses absolute /data paths. +# 2. chown the platform-mounted /data volume to the runtime user +# (managed platforms mount volumes root-owned 755 by default). +# 3. Generate the HMAC secret on first boot and persist it to +# /data/.hmac (chmod 600) so the secret survives restarts. +# +# Then it execs the agentmemory CLI under gosu as the unprivileged +# `node` user. + +set -eu + +DATA_DIR="${AGENTMEMORY_DATA_DIR:-/data}" +HMAC_FILE="${AGENTMEMORY_HMAC_FILE:-/data/.hmac}" +RUN_AS="node:node" +III_CONFIG="/opt/agentmemory/node_modules/@agentmemory/agentmemory/dist/iii-config.yaml" + +mkdir -p "$DATA_DIR" +chown -R "$RUN_AS" "$DATA_DIR" + +cat > "$III_CONFIG" <<'EOF' +workers: + - name: iii-http + config: + port: 3111 + host: 0.0.0.0 + default_timeout: 180000 + cors: + allowed_origins: + - "http://localhost:3111" + - "http://localhost:3113" + - "http://127.0.0.1:3111" + - "http://127.0.0.1:3113" + allowed_methods: [GET, POST, PUT, DELETE, OPTIONS] + - name: iii-state + config: + adapter: + name: kv + config: + store_method: file_based + file_path: /data/state_store.db + - name: iii-queue + config: + adapter: + name: builtin + - name: iii-pubsub + config: + adapter: + name: local + - name: iii-cron + config: + adapter: + name: kv + - name: iii-stream + config: + port: 3112 + host: 0.0.0.0 + adapter: + name: kv + config: + store_method: file_based + file_path: /data/stream_store + - name: iii-observability + config: + enabled: true + service_name: agentmemory + exporter: memory + sampling_ratio: 1.0 + metrics_enabled: true + logs_enabled: true + logs_console_output: true +EOF +chown "$RUN_AS" "$III_CONFIG" + +if [ ! -s "$HMAC_FILE" ]; then + SECRET="$(openssl rand -hex 32)" + umask 077 + printf '%s\n' "$SECRET" > "$HMAC_FILE" + chmod 600 "$HMAC_FILE" + chown "$RUN_AS" "$HMAC_FILE" + echo "================================================================" + echo "agentmemory: generated HMAC secret on first boot" + echo "AGENTMEMORY_SECRET=$SECRET" + echo "Copy this value now. It will not be printed again." + echo "Stored at: $HMAC_FILE (chmod 600)" + echo "To rotate: delete $HMAC_FILE on the persistent volume and restart." + echo "================================================================" +fi + +AGENTMEMORY_SECRET="$(cat "$HMAC_FILE")" +export AGENTMEMORY_SECRET + +exec gosu "$RUN_AS" agentmemory "$@" diff --git a/deploy/railway/railway.json b/deploy/railway/railway.json new file mode 100644 index 0000000..43f5217 --- /dev/null +++ b/deploy/railway/railway.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "deploy/railway/Dockerfile" + }, + "deploy": { + "numReplicas": 1, + "healthcheckPath": "/agentmemory/livez", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "requiredMountPath": "/data" + } +} diff --git a/deploy/render/Dockerfile b/deploy/render/Dockerfile new file mode 100644 index 0000000..3769815 --- /dev/null +++ b/deploy/render/Dockerfile @@ -0,0 +1,35 @@ +ARG III_VERSION=0.11.2 + +FROM iiidev/iii:${III_VERSION} AS iii-image + +FROM node:22-slim + +ARG AGENTMEMORY_VERSION=0.9.27 +ARG III_VERSION=0.11.2 +ARG III_SDK_VERSION=0.11.2 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssl ca-certificates tini gosu curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=iii-image /app/iii /usr/local/bin/iii + +# Install agentmemory into a dedicated prefix so the local package.json's +# `overrides` field pins iii-sdk down to match the engine (agentmemory's +# caret range `^0.11.2` otherwise resolves to 0.11.6, the version that +# requires the new sandbox-everything worker model the agentmemory CLI +# is not refactored for yet). `npm install -g` ignores overrides, hence +# the local prefix. +WORKDIR /opt/agentmemory +RUN printf '{"name":"agentmemory-deploy","version":"1.0.0","private":true,"overrides":{"iii-sdk":"%s"}}\n' "${III_SDK_VERSION}" > package.json \ + && npm install "@agentmemory/agentmemory@${AGENTMEMORY_VERSION}" --omit=optional --no-fund --no-audit \ + && ln -s /opt/agentmemory/node_modules/.bin/agentmemory /usr/local/bin/agentmemory + +ENV AGENTMEMORY_III_VERSION=${III_VERSION} \ + TINI_SUBREAPER=1 + +COPY --chmod=0755 entrypoint.sh /usr/local/bin/agentmemory-entrypoint.sh + +EXPOSE 3111 + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/agentmemory-entrypoint.sh"] diff --git a/deploy/render/README.md b/deploy/render/README.md new file mode 100644 index 0000000..3b73f7e --- /dev/null +++ b/deploy/render/README.md @@ -0,0 +1,113 @@ +# Deploy agentmemory on Render + +This template runs agentmemory on a single Render Web Service with a +persistent disk mounted at `/data`. The HMAC secret is generated on +first boot and persisted to the disk — you capture it from the deploy +logs exactly once. + +## What you get + +- A public HTTPS endpoint serving the agentmemory REST API on port 3111 + (Render injects `PORT` defaulting to 10000; we override it to 3111 + via `envVars` so the published port matches the container's bind) +- A 1 GB persistent disk at `/data` for memories, BM25 index, and + stream backlog +- Render healthcheck against `/agentmemory/livez` +- The HMAC bearer secret is generated on first boot inside the + container and persisted to `/data/.hmac` (chmod 600); the operator + copies it from the deploy logs once. + +## Deploy via Render Blueprint + +Render's one-click deploy button only auto-detects `render.yaml` at the +repository root, which the agentmemory repo keeps clean. Use the +dashboard's manual Blueprint flow instead: + +1. Push the `deploy/render/` directory to a Git provider Render can + reach (a fork of `rohitg00/agentmemory` works). +2. In the Render dashboard, click **New +** → **Blueprint**. +3. Point Render at the repo and the path `deploy/render/render.yaml`. +4. Render reads the Blueprint, provisions the disk, builds the + Dockerfile, and starts the service. The whole flow takes 3–5 + minutes on the first run. + +## Deploy via Render Deploy Hook (one-click) + +Once the Blueprint exists in your account, generate a Deploy Hook URL +in the service settings. Future deploys are a single curl call: + +```bash +curl "https://api.render.com/deploy/srv-XXYYZZ?key=AABBCC" +``` + +To pin a specific `@agentmemory/agentmemory` release, set the +`AGENTMEMORY_VERSION` build arg in the service's *Environment* tab +before the next deploy. Same for `III_VERSION`. + +## Capture the HMAC secret + +After the first deploy succeeds, open the service's **Logs** tab and +search for `AGENTMEMORY_SECRET=`. You will see exactly one line of the +form `AGENTMEMORY_SECRET=<64 hex chars>`. Copy it into your client +environment. The secret is never printed again on subsequent boots. + +## Verify the deployment + +```bash +curl https://agentmemory.onrender.com/agentmemory/livez +# {"status":"ok"} +``` + +For an authenticated call, your client must send `Authorization: Bearer `. + +## Viewer access (port 3113 stays internal) + +Render only exposes one public port per service, and we use it for +3111. The viewer port stays bound to localhost inside the container. +Reach it via Render's SSH: + +```bash +# Settings → SSH → enable for your service, copy the connection command +ssh srv-XXYYZZ@ssh..render.com -L 3113:localhost:3113 +# now http://localhost:3113 in your browser hits the in-container viewer +``` + +## Rotate the HMAC secret + +```bash +ssh srv-XXYYZZ@ssh..render.com +rm /data/.hmac +exit +# trigger a redeploy from the Render dashboard or via the Deploy Hook +``` + +After the redeploy, grab the new secret from the logs and update every +client. Old tokens stop working immediately. + +## Back up `/data` + +```bash +ssh srv-XXYYZZ@ssh..render.com "tar czf - /data" > agentmemory-$(date +%Y%m%d).tar.gz +``` + +Render also takes daily snapshots of persistent disks automatically on +paid plans — the SSH tarball is a belt-and-braces option you can ship +off-platform. + +## Cost floor and egress + +- Starter plan web service: $7/month (0.5 CPU, 512 MB RAM). +- 1 GB persistent disk: $0.25/GB/month, so $0.25/month for the default. +- Bandwidth: 100 GB outbound included, then $0.10/GB. + +See for the current rate card. + +## Known caveats + +- Render Free tier does not support persistent disks. The Starter plan + ($7/month) is the minimum. +- Render restarts the service on every deploy. The HMAC secret survives + because it lives on the disk, but expect a 10–30 s gap of 502s + during rollouts. +- Render runs amd64 only for web services. The Dockerfile selects the + matching iii binary automatically via `uname -m`. diff --git a/deploy/render/entrypoint.sh b/deploy/render/entrypoint.sh new file mode 100755 index 0000000..ffdd633 --- /dev/null +++ b/deploy/render/entrypoint.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# agentmemory first-boot entrypoint. +# +# Runs as root so it can: +# 1. Overwrite the npm-bundled iii-config.yaml (which binds 127.0.0.1 +# and uses relative ./data paths) with a deploy-tuned version that +# binds 0.0.0.0 and uses absolute /data paths. +# 2. chown the platform-mounted /data volume to the runtime user +# (managed platforms mount volumes root-owned 755 by default). +# 3. Generate the HMAC secret on first boot and persist it to +# /data/.hmac (chmod 600) so the secret survives restarts. +# +# Then it execs the agentmemory CLI under gosu as the unprivileged +# `node` user. + +set -eu + +DATA_DIR="${AGENTMEMORY_DATA_DIR:-/data}" +HMAC_FILE="${AGENTMEMORY_HMAC_FILE:-/data/.hmac}" +RUN_AS="node:node" +III_CONFIG="/opt/agentmemory/node_modules/@agentmemory/agentmemory/dist/iii-config.yaml" + +mkdir -p "$DATA_DIR" +chown -R "$RUN_AS" "$DATA_DIR" + +cat > "$III_CONFIG" <<'EOF' +workers: + - name: iii-http + config: + port: 3111 + host: 0.0.0.0 + default_timeout: 180000 + cors: + allowed_origins: + - "http://localhost:3111" + - "http://localhost:3113" + - "http://127.0.0.1:3111" + - "http://127.0.0.1:3113" + allowed_methods: [GET, POST, PUT, DELETE, OPTIONS] + - name: iii-state + config: + adapter: + name: kv + config: + store_method: file_based + file_path: /data/state_store.db + - name: iii-queue + config: + adapter: + name: builtin + - name: iii-pubsub + config: + adapter: + name: local + - name: iii-cron + config: + adapter: + name: kv + - name: iii-stream + config: + port: 3112 + host: 0.0.0.0 + adapter: + name: kv + config: + store_method: file_based + file_path: /data/stream_store + - name: iii-observability + config: + enabled: true + service_name: agentmemory + exporter: memory + sampling_ratio: 1.0 + metrics_enabled: true + logs_enabled: true + logs_console_output: true +EOF +chown "$RUN_AS" "$III_CONFIG" + +if [ ! -s "$HMAC_FILE" ]; then + SECRET="$(openssl rand -hex 32)" + umask 077 + printf '%s\n' "$SECRET" > "$HMAC_FILE" + chmod 600 "$HMAC_FILE" + chown "$RUN_AS" "$HMAC_FILE" + echo "================================================================" + echo "agentmemory: generated HMAC secret on first boot" + echo "AGENTMEMORY_SECRET=$SECRET" + echo "Copy this value now. It will not be printed again." + echo "Stored at: $HMAC_FILE (chmod 600)" + echo "To rotate: delete $HMAC_FILE on the persistent volume and restart." + echo "================================================================" +fi + +AGENTMEMORY_SECRET="$(cat "$HMAC_FILE")" +export AGENTMEMORY_SECRET + +exec gosu "$RUN_AS" agentmemory "$@" diff --git a/deploy/render/render.yaml b/deploy/render/render.yaml new file mode 100644 index 0000000..d269469 --- /dev/null +++ b/deploy/render/render.yaml @@ -0,0 +1,22 @@ +services: + - type: web + name: agentmemory + runtime: docker + plan: starter + dockerfilePath: ./deploy/render/Dockerfile + dockerContext: ./deploy/render + healthCheckPath: /agentmemory/livez + autoDeploy: false + disk: + name: data + mountPath: /data + sizeGB: 1 + envVars: + - key: PORT + value: "3111" + - key: AGENTMEMORY_VERSION + value: "0.9.27" + - key: III_VERSION + value: "0.11.2" + - key: III_SDK_VERSION + value: "0.11.2" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6b11765 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + # One-shot init container: docker creates named volumes root:root mode + # 755, but the iii-engine image is distroless and runs as UID 65532 + # with no `chown` of its own. Without this, /data is unwritable, the + # engine silently buffers in RAM, and state evaporates on every + # restart — the exact symptom v0.9.7's working-directory fix set out + # to solve. Runs once at compose-up and exits. + iii-init: + image: busybox:1.36 + user: "0:0" + volumes: + - iii-data:/data + entrypoint: ["sh", "-c", "chown -R 65532:65532 /data && chmod 755 /data"] + restart: "no" + + iii-engine: + # Pinned to v0.11.2 — the last engine that runs agentmemory's current + # worker model cleanly. v0.11.6 introduces a new sandbox-everything- + # via-`iii worker add` model that agentmemory hasn't been refactored + # for yet; the architectural mismatch surfaces as EPIPE reconnect + # loops and empty search after save. Bump only after agentmemory is + # refactored to register as a sandboxed worker. + # + # Override per-shell or via .env file: + # AGENTMEMORY_III_VERSION=0.11.7 docker compose up + image: iiidev/iii:${AGENTMEMORY_III_VERSION:-0.11.2} + user: "65532:65532" + depends_on: + iii-init: + condition: service_completed_successfully + ports: + - "127.0.0.1:49134:49134" + - "127.0.0.1:3111:3111" + - "127.0.0.1:3112:3112" + - "127.0.0.1:9464:9464" + volumes: + - iii-data:/data + - ./iii-config.docker.yaml:/app/config.yaml:ro + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + iii-data: diff --git a/docs/benchmarks/2026-05-20-coding-agent-life-v1.md b/docs/benchmarks/2026-05-20-coding-agent-life-v1.md new file mode 100644 index 0000000..6ee8577 --- /dev/null +++ b/docs/benchmarks/2026-05-20-coding-agent-life-v1.md @@ -0,0 +1,101 @@ +# 2026-05-20 — coding-agent-life-v1 (v0.9.26) + +**Bench:** coding-agent-life-v1 (15 sessions, 15 queries) +**N:** 15 +**K:** 5 +**Hardware:** macOS 15 (Apple Silicon) +**agentmemory:** v0.9.26 +**iii-engine:** v0.11.2 +**Embedding provider:** local default +**Sandbox:** isolated data dir at `/tmp/agentmemory-eval-sandbox/`, ports 3411/3412 + +## Math ceiling on this dataset + +12 of 15 questions have 1 gold session, 3 have 2 gold sessions. Per +`scoreQuestion` in `eval/runner/score.ts`, P@K = `hits / k` averaged +across questions, so the **maximum achievable P@5** is: + +``` +(12 * 1/5) + (3 * 2/5)) / 15 = (2.4 + 1.2) / 15 = 0.240 +``` + +R@5 ceiling is **1.000** (every gold session found in top-5). + +The benchmark is **small** (15 questions) and **gold-sparse** (mostly +single-gold). It's tuned for fast iteration on the retrieval stack, +not for headline P@K comparisons. **Recall** + per-question-type +**P@5** are the signals; aggregate P@5 saturates at 0.240 so it can't +differentiate top-tier adapters. + +## Headline + +`agentmemory-hybrid` hits **100% top-5 hit rate**, R@5 = **1.000**, +P@5 = **0.240** (at the math ceiling — every gold session retrieved +in top-5). + +grep baseline: R@5 = **0.967**, P@5 = **0.227** — missed one gold +session in one multi-gold question. Lift is **recall**, not aggregate +precision. + +## Per-adapter + +| Adapter | P@5 | R@5 | Hit rate | p50 latency | +|---|---|---|---|---| +| grep (tokenized substring) | 0.227 | 0.967 | 15 / 15 | 0 ms | +| `agentmemory-hybrid` | **0.240** | **1.000** | **15 / 15** | 14 ms | + +`agentmemory-hybrid` runs through the production smart-search endpoint (`POST /agentmemory/smart-search`) so it exercises the full BM25 + embedding + reranker stack. + +## Per-question-type + +At K=5 with 1 gold per single-session question, the P@5 ceiling per +question is **0.20**; with 2 gold the ceiling is **0.40**. Both +adapters saturate the per-type ceiling on most types, so the per-type +table primarily exposes where one adapter **misses** gold (failing +the recall side). + +| Type | grep P@5 | grep R@5 | hybrid P@5 | hybrid R@5 | +|---|---|---|---|---| +| single-session-bug | 0.20 | 1.00 | 0.20 | 1.00 | +| single-session-infra (n=2) | 0.20 | 1.00 | 0.20 | 1.00 | +| single-session-refactor | 0.20 | 1.00 | 0.20 | 1.00 | +| single-session-feature | 0.20 | 1.00 | 0.20 | 1.00 | +| single-session-test | 0.20 | 1.00 | 0.20 | 1.00 | +| single-session-perf | 0.20 | 1.00 | 0.20 | 1.00 | +| single-session-api | 0.20 | 1.00 | 0.20 | 1.00 | +| single-session-db | 0.20 | 1.00 | 0.20 | 1.00 | +| single-session-release | 0.20 | 1.00 | 0.20 | 1.00 | +| multi-session-causal (2 gold) | 0.40 | 1.00 | 0.40 | 1.00 | +| preference (n=2) | 0.20 | 1.00 | 0.20 | 1.00 | +| multi-session-review (2 gold) | 0.40 | 1.00 | 0.40 | 1.00 | +| temporal (2 gold) | 0.20 | 0.50 | 0.40 | 1.00 | + +The differentiator at this corpus size is **temporal** (`What was shipped on April 8th 2026?`): grep finds 1 of 2 gold sessions, hybrid finds both. Per-type R@5 saturates everywhere else. + +## Methodology + +- 15 fictional Claude Code sessions across a 10-day stretch of a Rust CLI project (`shipctl`) — bug fixes, refactors, infra, perf, schema migrations, preferences, post-mortem +- 15 hand-graded queries with `goldSessionIds[]` covering single-session, multi-session causal, multi-session review, preference, temporal +- Each session ingested via `POST /agentmemory/remember` with `type=eval-session` and `concepts=[session_id]` +- Each query hits `POST /agentmemory/smart-search` with `limit=50`; dedupe by session ID; truncate to K=5 +- No LLM in the retrieval loop +- Sandbox: clean `~/.agentmemory` via `HOME` override + alt ports (3411/3412) so no cross-contamination from a user's real store + +## Reproduce + +```sh +git checkout e9dc710 +npm install --legacy-peer-deps +npm run build + +source eval/scripts/sandbox.sh +npm run eval:coding-life -- --adapters grep,agentmemory +``` + +Outputs land in `eval/reports/coding-life/`: `scores.ndjson` (per-query rows) and `summary.json` (per-adapter and per-type aggregates). + +## Notes + +- The single-session-feature tie (`Which PR introduced helm chart support?`) is interesting: query says `PR introduced helm chart` and gold session has `helm chart` literally — grep wins on lexical exactness, hybrid matches but doesn't outperform. +- The corpus is intentionally small for fast iteration. Hardening targets: paraphrased queries, synonym substitution, in-corpus distractors with shared keywords, longer multi-session chains. +- Vector adapter not measured here — requires `OPENAI_API_KEY`; will be added in a follow-up scorecard alongside LongMemEval `_s`. diff --git a/docs/benchmarks/TEMPLATE.md b/docs/benchmarks/TEMPLATE.md new file mode 100644 index 0000000..b830e24 --- /dev/null +++ b/docs/benchmarks/TEMPLATE.md @@ -0,0 +1,54 @@ +# + +**Commit:** `` +**Bench:** LongMemEval `_s` / coding-agent-life-v1 / ... +**N:** 500 / 15 / ... +**K:** 5 +**Hardware:** macos-15 / ubuntu-22.04 / ... +**OpenAI model:** text-embedding-3-small +**Anthropic model:** N/A (no LLM in retrieval loop) + +## Headline + +agentmemory-hybrid: **R@5 = XX.XX%**, P@5 = XX.XX%, p50 latency = XXms + +Beats grep baseline by +X.Xpt R@5, vector by +X.Xpt R@5. + +## Per-adapter + +| Adapter | P@5 | R@5 | Hit rate | p50 latency | +|---|---|---|---|---| +| grep | | | | | +| vector | | | | | +| agentmemory-hybrid | | | | | + +## Per-question-type + +| Type | grep R@5 | vector R@5 | agentmemory R@5 | +|---|---|---|---| +| single-session-bug | | | | +| single-session-refactor | | | | +| preference | | | | +| multi-session-causal | | | | +| temporal | | | | + +## Methodology + +- Sessions ingested via `POST /agentmemory/remember` with `type=eval-session` +- Queries hit `POST /agentmemory/smart-search` with `limit=k*4` +- No LLM in retrieval loop. Direct rank from hybrid scoring. +- Ranks dedup by sessionId before truncating to K +- Latency measured as init+query for LongMemEval (per-question fresh state), query-only for coding-life (shared state) + +## Reproduce + +```sh +git checkout +npm install --legacy-peer-deps +OPENAI_API_KEY=sk-... AGENTMEMORY_BASE_URL=http://localhost:3111 \ + npm run eval:longmemeval -- --stratify 10 +``` + +## Notes + + diff --git a/docs/recipes/pairings.md b/docs/recipes/pairings.md new file mode 100644 index 0000000..093abf8 --- /dev/null +++ b/docs/recipes/pairings.md @@ -0,0 +1,119 @@ +# Pairings + +Open-source projects shipping the rest of the AI coding agent context layer. agentmemory ships persistent session memory. The projects below ship code-graph indexing, multi-agent build pipelines with dashboards, and broader knowledge graphs across non-code assets. Stack them and your agent gets a fuller picture in fewer tool calls. + +## [codegraph](https://github.com/colbymchenry/codegraph) — pre-indexed code knowledge graph (MCP) + +What it does: builds a SQLite-backed code knowledge graph (symbols, call edges, route handlers, full-text search) and exposes it through an MCP server with 8 tools (`codegraph_search`, `codegraph_context`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact`, `codegraph_node`, `codegraph_status`, `codegraph_files`). File watcher keeps the index fresh. Their published benchmark (across VS Code, Django, Tokio, OkHttp, Gin, Alamofire, Excalidraw) shows agents finishing the same architecture question in **~35% less cost, ~70% fewer tool calls** when codegraph is wired in. + +Recipe with agentmemory — both as MCP servers on the same agent: + +```jsonc +// ~/.claude.json or your agent's MCP config +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "codegraph", + "args": ["serve", "--mcp"] + }, + "agentmemory": { + "type": "stdio", + "command": "npx", + "args": ["@agentmemory/mcp"] + } + } +} +``` + +Question routing that falls out: + +| Question | Tool the agent reaches for | +|---|---| +| "What does `shipctl helm install` call?" | `codegraph_callees` | +| "Where is `auth_check` defined?" | `codegraph_node` | +| "Which routes hit the user controller?" | `codegraph_callers` on the controller node | +| "What did we decide last week about retries?" | `memory_smart_search` | +| "Why did we pick async-std?" | `memory_recall` | +| "Fix the auth bug from the post-mortem" | `memory_smart_search` → post-mortem session, then `codegraph_node` → current `auth.rs` | + +## [Understand Anything](https://github.com/Lum1104/Understand-Anything) — multi-agent code-graph pipeline + dashboard + +What it does: a Claude Code plugin (also installable on 13+ other agents) runs a multi-agent pipeline over a project and produces an interactive web dashboard at `understand-anything.com`. Architecture layers, business-flow domain view, guided tours, persona-adaptive UI, diff impact analysis, framework-aware routes across 14 frameworks. The graph commits to `.understand-anything/knowledge-graph.json` so teammates skip the pipeline. + +Slash commands: +- `/understand` — build the graph (incremental on re-run) +- `/understand-dashboard` — open the interactive web dashboard +- `/understand-chat` — ask anything about the codebase +- `/understand-diff` — analyze impact of current changes +- `/understand-explain` — deep-dive a file or function +- `/understand-onboard` — generate an onboarding guide +- `/understand-domain` — extract business domains, flows, steps +- `/understand-knowledge` — analyze an LLM wiki knowledge base + +Recipe with agentmemory: + +```bash +# Day 1 — new team member joins +/understand # builds the graph +/understand-dashboard # opens the visual map +/understand-onboard # generates onboarding guide + +# Week 2 — same engineer hits a bug +/understand-explain src/auth.rs # architecture context +# agentmemory MCP surfaces the post-mortem and the past 3 fixes touching auth.rs +``` + +The graph teaches you the codebase. agentmemory remembers what you and the agent already did inside it. The two planes don't overlap. + +## [Graphify](https://github.com/safishamsi/graphify) — broader knowledge graph across code, docs, PDFs, images, videos + +What it does: a single slash command (`/graphify .`) maps a whole project — application code, SQL schemas, R scripts, shell scripts, docs, PDFs, papers, images, videos — into one queryable knowledge graph. Output is three files: an interactive `graph.html`, a markdown `GRAPH_REPORT.md` with highlights and suggested questions, and the full `graph.json`. Also ships `graphify export callflow-html` for Mermaid call-flow architecture pages. + +Runs as a skill on Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kimi Code, Kiro, Pi, and Google Antigravity. + +Recipe with agentmemory: + +```bash +/graphify . # one graph: code + docs + PDFs + images +``` + +This is the broadest sweep across artifacts that live alongside the code. agentmemory then captures everything the agent does while exploring that graph — the questions you asked, the conclusions, the decisions — so the next session opens with both the graph and the conversation history available. + +## How the four projects line up + +Four planes, four consumers, four update models. None of them try to do what the others do. + +| Project | Plane | Surface | Consumer | Update model | +|---|---|---|---|---| +| [agentmemory](https://github.com/rohitg00/agentmemory) | session history (observations, decisions, preferences) | MCP + REST + hooks | agent | live (observations stream) | +| [codegraph](https://github.com/colbymchenry/codegraph) | code structure (symbols, call edges, routes) | MCP server | agent | live (FS watcher) | +| [Understand Anything](https://github.com/Lum1104/Understand-Anything) | code structure + business domain + architecture | plugin (slash commands) + web dashboard | **human** + agent | on-demand `/understand` | +| [Graphify](https://github.com/safishamsi/graphify) | code + docs + PDFs + images + videos | skill (slash command) | human + agent | on-demand `/graphify` | + +## Question types each project handles best + +| Question shape | Best tool | +|---|---| +| "What's the architecture of this repo?" | Understand-Anything dashboard or Graphify `graph.html` | +| "Where is symbol X defined? Who calls it?" | codegraph (`codegraph_node`, `codegraph_callers`) | +| "What does this PDF spec say about the rate limit?" | Graphify | +| "Why did we pick X over Y three sessions ago?" | agentmemory (`memory_smart_search`) | +| "What did we ship on April 8?" | agentmemory (`memory_timeline`) | +| "How does the payment flow work in this codebase?" | Understand-Anything (`/understand-chat`) | +| "Trace impact of changing `Foo::bar`" | codegraph (`codegraph_impact`) or Understand-Anything (`/understand-diff`) | +| "What preferences has the team locked in?" | agentmemory | + +## Suggested install order for a brand-new project + +1. **agentmemory** — observe and persist from day one, even before the codebase has structure. Run `npx @agentmemory/agentmemory connect` and pick your agent. +2. **codegraph** — once code lands, agent queries answer from the index instead of grepping. Run `npx @colbymchenry/codegraph`. +3. **Understand Anything** *or* **Graphify** — when the codebase passes a few thousand LOC or starts shipping docs and PDFs alongside code, generate the graph for visual exploration and onboarding. Run `/plugin install understand-anything` or `uv tool install graphifyy && graphify install`. + +All four are local-first (no data leaves the machine for code-graph workloads). Stacking them costs nothing extra at the network boundary. + +## Cross-project benchmark idea + +`eval/runner/adapters/` accepts new adapters against the same coding-agent-life-v1 corpus and the published LongMemEval `_s` benchmark. A `codegraph` adapter or an `understand-anything` adapter or a `graphify` adapter would let us publish a side-by-side scorecard showing which project owns which question class. The win for the ecosystem is precise framing: each project gets credit for what it does best, with reproducible numbers from a shared harness. + +If you build any of those adapters, open a PR against `agentmemory` with the adapter file under `eval/runner/adapters/` and a scorecard under `docs/benchmarks/`. The scaffold and contract live in [`eval/README.md`](../../eval/README.md). diff --git a/eval/README.md b/eval/README.md new file mode 100644 index 0000000..7f29536 --- /dev/null +++ b/eval/README.md @@ -0,0 +1,111 @@ +# agentmemory-evals + +Public benchmarks for agentmemory's hybrid memory stack (BM25 + embeddings + consolidation + graph). + +Two families, both reproducible: + +- **LongMemEval** — public 500-question retrieval benchmark over multi-session chat +- **coding-agent-life-v1** — in-house corpus of 15 fictional Claude Code sessions for a Rust CLI project (`shipctl`), with 15 hand-graded queries covering bug fixes, refactors, preferences, and multi-session causal reasoning + +## Adapters + +| Adapter | Backend | API key needed | +|---|---|---| +| `grep` | Tokenized substring match | none | +| `vector` | OpenAI `text-embedding-3-small` + cosine | `OPENAI_API_KEY` | +| `agentmemory` | Running agentmemory server, smart-search endpoint | none (auth optional via `AGENTMEMORY_SECRET`) | + +## Sandbox first + +Running the `agentmemory` adapter against your real `~/.agentmemory` directory pollutes the eval with pre-existing memories AND pollutes your real store with eval test data. Always sandbox. + +`eval/scripts/sandbox.sh` spins up a clean agentmemory + iii-engine on ports 3411/3412 with state in `/tmp/agentmemory-eval-sandbox/`, exports `AGENTMEMORY_BASE_URL`, and tears down on exit. + +```sh +source eval/scripts/sandbox.sh +npm run eval:coding-life -- --adapters grep,agentmemory +``` + +Requires iii v0.11.2 on PATH (agentmemory pin). If you already have a different version installed, install the pinned build into `~/.local/bin` and make sure that directory comes first on `PATH`: + +```sh +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 +export PATH="$HOME/.local/bin:$PATH" # add to ~/.zshrc or ~/.bashrc for persistence +``` + +## Quickstart + +### coding-agent-life-v1 (in-house, no download) + +```sh +# grep baseline, no sandbox needed +npm run eval:coding-life -- --adapters grep + +# add agentmemory + vector (sandbox + OpenAI key) +source eval/scripts/sandbox.sh +OPENAI_API_KEY=sk-... npm run eval:coding-life -- --adapters grep,vector,agentmemory +``` + +### LongMemEval `_s` (public, 278MB download) + +```sh +mkdir -p ~/datasets/longmemeval +curl -Lo ~/datasets/longmemeval/longmemeval_s.json \ + https://huggingface.co/datasets/xiaowu0162/longmemeval/resolve/main/longmemeval_s + +source eval/scripts/sandbox.sh + +# Stratified sample of 10 per type (fast iteration, ~$0.20 OpenAI cost) +OPENAI_API_KEY=sk-... LONGMEMEVAL_PATH=~/datasets/longmemeval/longmemeval_s.json \ + npm run eval:longmemeval -- --stratify 10 + +# Full 500 questions × 3 adapters (~$2 OpenAI cost) +OPENAI_API_KEY=sk-... LONGMEMEVAL_PATH=~/datasets/longmemeval/longmemeval_s.json \ + npm run eval:longmemeval +``` + +## Repo layout + +```text +eval/ +├── README.md +├── runner/ +│ ├── types.ts Adapter, Question, RankedDoc, ScoreRow +│ ├── score.ts P@K, R@K, aggregation +│ ├── load.ts LongMemEval JSON → Question[] +│ ├── adapters/ +│ │ ├── grep.ts tokenized substring baseline +│ │ ├── vector.ts OpenAI embeddings + cosine +│ │ └── agentmemory.ts POST /agentmemory/{remember,smart-search} +│ ├── longmemeval.ts public benchmark runner +│ └── coding-life.ts in-house benchmark runner +└── data/ + └── coding-agent-life-v1/ + ├── sessions.json 15 fictional sessions (~6KB) + └── queries.json 15 queries with gold session IDs +``` + +Reports land in `eval/reports//` (gitignored): `scores.ndjson` + `summary.json`. + +Published scorecards land in `docs/benchmarks/YYYY-MM-DD-.md`. + +## Writing a new adapter + +1. Implement `Adapter` from `eval/runner/types.ts`: + ```ts + import type { Adapter } from "../types.js"; + export const myAdapter: Adapter = { + name: "my-adapter", + async init(sessions, config) { /* index */ return state; }, + async query(q, state, k) { /* search */ return ranked; }, + }; + ``` +2. Register in `eval/runner/{longmemeval,coding-life}.ts` `ADAPTERS` map. +3. Run against `coding-agent-life-v1` to sanity-check before committing OpenAI spend on LongMemEval. + +## Why a benchmark for agentmemory + +agentmemory ships BM25 + embeddings + consolidation + graph retrieval. Numbers from those layers should be measured against grep/vector baselines so the value of each layer is provable. + +The in-house corpus is small on purpose (15 sessions) — covers single-session, multi-session, preference, and temporal question types without taking 15 minutes to run. LongMemEval gives the public-comparison axis. diff --git a/eval/data/coding-agent-life-v1/queries.json b/eval/data/coding-agent-life-v1/queries.json new file mode 100644 index 0000000..5603e8a --- /dev/null +++ b/eval/data/coding-agent-life-v1/queries.json @@ -0,0 +1,107 @@ +[ + { + "id": "q-001", + "type": "single-session-bug", + "question": "Where did we land the auth env var precedence fix?", + "answer": "PR #11 with SHIPCTL_TOKEN > SHIP_TOKEN > SC_TOKEN precedence", + "goldSessionIds": ["sess-001"] + }, + { + "id": "q-002", + "type": "single-session-infra", + "question": "What was the multi-arch Docker fix?", + "answer": "Added --platform=$BUILDPLATFORM and BUILDX_PLATFORMS for amd64+arm64", + "goldSessionIds": ["sess-002"] + }, + { + "id": "q-003", + "type": "single-session-refactor", + "question": "Where did we consolidate the retry logic?", + "answer": "src/retry.rs with exponential backoff base=200ms cap=30s full jitter", + "goldSessionIds": ["sess-003"] + }, + { + "id": "q-004", + "type": "single-session-feature", + "question": "Which PR introduced helm chart support?", + "answer": "PR #14", + "goldSessionIds": ["sess-004"] + }, + { + "id": "q-005", + "type": "single-session-test", + "question": "Which test was flaky on macos and how was it fixed?", + "answer": "fs-watcher emits_changekind_file_delete; bumped wait to 1500ms + retry: 2", + "goldSessionIds": ["sess-005"] + }, + { + "id": "q-006", + "type": "single-session-perf", + "question": "How did we fix the memory leak?", + "answer": "Replaced unbounded HashMap with LruCache cap=10k in src/cache.rs (PR #16)", + "goldSessionIds": ["sess-006"] + }, + { + "id": "q-007", + "type": "single-session-api", + "question": "How did we handle the github API rate limit?", + "answer": "Conditional requests with If-None-Match etag and 304 caching via http-cache", + "goldSessionIds": ["sess-007"] + }, + { + "id": "q-008", + "type": "single-session-db", + "question": "What was the schema migration approach for run_history?", + "answer": "Three-phase: nullable column + dual-write, backfill + flip reads, drop old column", + "goldSessionIds": ["sess-008"] + }, + { + "id": "q-009", + "type": "single-session-infra", + "question": "How is the docs site deployed?", + "answer": "GitHub Actions docs.yml workflow + mdbook build + Cloudflare Pages on shipctl.dev", + "goldSessionIds": ["sess-009"] + }, + { + "id": "q-010", + "type": "single-session-release", + "question": "Which PR set up the cross-platform release pipeline?", + "answer": "PR #19 with cross-rs for linux and native macos/windows builds", + "goldSessionIds": ["sess-010"] + }, + { + "id": "q-011", + "type": "multi-session-causal", + "question": "What was the root cause of the staging incident, and where was it fixed?", + "answer": "SHIPCTL_TOKEN unset caused fallback to bad SC_TOKEN; fixed in PR #11 (sess-001) with precedence test; documented in post-mortem (sess-014)", + "goldSessionIds": ["sess-001", "sess-014"] + }, + { + "id": "q-012", + "type": "preference", + "question": "Which async runtime does the team prefer for new code?", + "answer": "async-std (decided in arch review, not tokio)", + "goldSessionIds": ["sess-015"] + }, + { + "id": "q-013", + "type": "preference", + "question": "What are the user's formatting preferences?", + "answer": "2-space indent in yaml, tab in rust, rust-analyzer over rls, cargo clippy before commit", + "goldSessionIds": ["sess-012"] + }, + { + "id": "q-014", + "type": "multi-session-review", + "question": "What was the race condition Aria fixed in the schema migration?", + "answer": "Dual-write step had race when two writers hit same row; Aria added per-row advisory lock", + "goldSessionIds": ["sess-008", "sess-013"] + }, + { + "id": "q-015", + "type": "temporal", + "question": "What was shipped on April 8th 2026?", + "answer": "Auth env-var precedence fix in PR #11, then started docker arm64 work", + "goldSessionIds": ["sess-001", "sess-011"] + } +] diff --git a/eval/data/coding-agent-life-v1/sessions.json b/eval/data/coding-agent-life-v1/sessions.json new file mode 100644 index 0000000..7113413 --- /dev/null +++ b/eval/data/coding-agent-life-v1/sessions.json @@ -0,0 +1,77 @@ +[ + { + "id": "sess-001", + "timestamp": "2026-04-08T09:14:00Z", + "content": "[user] auth check broken on staging when SHIPCTL_TOKEN unset, falling back to wrong env var\n[assistant] looked at src/auth.rs:42. precedence is SHIPCTL_TOKEN > SHIP_TOKEN > legacy SC_TOKEN. legacy path didn't validate. fixing precedence + adding length check.\n[user] yeah ship it\n[assistant] PR #11 opened with fix + regression test. covers all three env names." + }, + { + "id": "sess-002", + "timestamp": "2026-04-08T14:30:00Z", + "content": "[user] docker build failing for arm64 on M2 runner\n[assistant] missing target in Dockerfile FROM line. need --platform=$BUILDPLATFORM on builder stage. updated Dockerfile + Makefile to set BUILDX_PLATFORMS=linux/amd64,linux/arm64. tested locally on apple silicon, builds clean." + }, + { + "id": "sess-003", + "timestamp": "2026-04-09T10:02:00Z", + "content": "[user] retry logic is duplicated across handlers. consolidate.\n[assistant] extracted retry_with_backoff into src/retry.rs. exponential backoff base=200ms cap=30s jitter=full. all 4 callsites now use it. cuts ~80 lines." + }, + { + "id": "sess-004", + "timestamp": "2026-04-10T11:45:00Z", + "content": "[user] add helm chart support\n[assistant] new subcommand `shipctl helm install`. wraps helm CLI, threads through --namespace and --values. PR #14 has the changes + 12 integration tests. Aria reviewed and approved." + }, + { + "id": "sess-005", + "timestamp": "2026-04-11T16:20:00Z", + "content": "[user] test flake on macos-22 runner. emits_changekind_file_delete keeps failing\n[assistant] fsevents propagation slower on apple silicon, the 800ms wait isn't enough. bumped to 1500ms + describe-level retry: 2. all 15 fs-watcher tests pass now." + }, + { + "id": "sess-006", + "timestamp": "2026-04-12T09:50:00Z", + "content": "[user] memory leak in long-running daemon. RSS grows 5MB/min.\n[assistant] traced to unbounded HashMap in src/cache.rs. swapped for LruCache with 10k cap. RSS stable at 180MB after 6h soak test. PR #16." + }, + { + "id": "sess-007", + "timestamp": "2026-04-13T13:15:00Z", + "content": "[user] github API rate-limiting us when listing PRs. 5000/hr isn't enough during ci\n[assistant] added conditional requests with If-None-Match etag. cache 304 responses. drops actual API calls by ~70%. used http-cache crate." + }, + { + "id": "sess-008", + "timestamp": "2026-04-15T10:30:00Z", + "content": "[user] schema migration for run_history table needs zero-downtime\n[assistant] three-phase migration. phase 1: add nullable new column + dual-write. phase 2: backfill + flip reads. phase 3: drop old column. avoids ALTER TABLE locks. ran on staging, took 4s for 2M rows." + }, + { + "id": "sess-009", + "timestamp": "2026-04-16T15:40:00Z", + "content": "[user] docs site is stale. set up auto-deploy from main\n[assistant] added .github/workflows/docs.yml. mdbook build + cloudflare pages deploy. domain shipctl.dev wired up. preview deploys on PRs." + }, + { + "id": "sess-010", + "timestamp": "2026-04-17T11:00:00Z", + "content": "[user] release pipeline. cut v0.3.0 with binaries for linux/macos/windows\n[assistant] new .github/workflows/release.yml. cross-rs for static linux, native for macos + windows. uploads to gh release + crates.io publish. tag-triggered. PR #19 has the workflow." + }, + { + "id": "sess-011", + "timestamp": "2026-04-08T17:00:00Z", + "content": "[user] standup notes: shipped #11 auth fix this morning. starting on docker arm64 next." + }, + { + "id": "sess-012", + "timestamp": "2026-04-10T18:30:00Z", + "content": "[user] preferences: I like rust analyzer over rls. always run cargo clippy before commit. prefer 2-space indent in yaml but tab in rust." + }, + { + "id": "sess-013", + "timestamp": "2026-04-15T19:00:00Z", + "content": "[assistant] reviewed Aria's PR #18 (schema migration). flagged race condition in dual-write step when two writers hit same row. Aria added per-row advisory lock. lgtm now." + }, + { + "id": "sess-014", + "timestamp": "2026-04-16T20:10:00Z", + "content": "[user] post-mortem from prod incident last week: SHIPCTL_TOKEN was unset in staging, fell back to bad SC_TOKEN which had wrong perms. delivery delayed 40min. action items: (1) precedence test (done in #11), (2) startup validation, (3) alert on auth fallback." + }, + { + "id": "sess-015", + "timestamp": "2026-04-17T16:45:00Z", + "content": "[user] preferences: stick to async-std not tokio for new code. team agreed in arch review." + } +] diff --git a/eval/runner/adapters/agentmemory.ts b/eval/runner/adapters/agentmemory.ts new file mode 100644 index 0000000..38028a7 --- /dev/null +++ b/eval/runner/adapters/agentmemory.ts @@ -0,0 +1,93 @@ +import type { Adapter, RankedDoc, Session } from "../types.js"; + +interface AgentMemoryState { + baseUrl: string; + secret?: string; + sessions: Session[]; + observationToSession: Map; +} + +interface RememberResponse { + memory?: { id?: string }; + observationId?: string; + id?: string; + observation?: { id?: string }; +} + +interface SmartSearchResponse { + results?: Array<{ + obsId?: string; + id?: string; + observationId?: string; + sessionId?: string; + score?: number; + content?: string; + }>; + observations?: Array<{ + obsId?: string; + id?: string; + sessionId?: string; + score?: number; + content?: string; + }>; +} + +function authHeaders(secret?: string): Record { + const h: Record = { "Content-Type": "application/json" }; + if (secret) h.Authorization = `Bearer ${secret}`; + return h; +} + +export const agentmemoryAdapter: Adapter = { + name: "agentmemory-hybrid", + async init(sessions, config) { + const baseUrl = (config?.baseUrl as string) ?? process.env.AGENTMEMORY_BASE_URL ?? "http://localhost:3111"; + const secret = (config?.secret as string) ?? process.env.AGENTMEMORY_SECRET; + const observationToSession = new Map(); + for (const s of sessions) { + const res = await fetch(`${baseUrl}/agentmemory/remember`, { + method: "POST", + headers: authHeaders(secret), + body: JSON.stringify({ + content: s.content, + type: "eval-session", + concepts: [s.id], + }), + }); + if (!res.ok) { + throw new Error(`remember failed for ${s.id}: ${res.status} ${await res.text()}`); + } + const body = (await res.json()) as RememberResponse; + const obsId = + body.memory?.id ?? body.observationId ?? body.id ?? body.observation?.id; + if (obsId) observationToSession.set(obsId, s.id); + } + return { baseUrl, secret, sessions, observationToSession }; + }, + async query(q, state, k) { + const res = await fetch(`${state.baseUrl}/agentmemory/smart-search`, { + method: "POST", + headers: authHeaders(state.secret), + body: JSON.stringify({ query: q, limit: Math.max(k * 10, 50) }), + }); + if (!res.ok) { + throw new Error(`smart-search failed: ${res.status} ${await res.text()}`); + } + const body = (await res.json()) as SmartSearchResponse; + const rows = body.results ?? body.observations ?? []; + const ranked: RankedDoc[] = []; + const seen = new Set(); + for (const row of rows) { + let sessionId = row.sessionId; + if (!sessionId) { + const memId = row.obsId ?? row.id ?? row.observationId; + sessionId = memId ? state.observationToSession.get(memId) : undefined; + } + if (!sessionId || seen.has(sessionId)) continue; + seen.add(sessionId); + ranked.push({ sessionId, score: row.score ?? 0 }); + if (ranked.length >= k) break; + } + return ranked; + }, +}; diff --git a/eval/runner/adapters/grep.ts b/eval/runner/adapters/grep.ts new file mode 100644 index 0000000..28b18ea --- /dev/null +++ b/eval/runner/adapters/grep.ts @@ -0,0 +1,36 @@ +import type { Adapter, RankedDoc, Session } from "../types.js"; + +interface GrepState { + sessions: Session[]; +} + +function tokenize(s: string): string[] { + return s + .toLowerCase() + .replace(/[^a-z0-9_]+/g, " ") + .split(/\s+/) + .filter((t) => t.length > 2); +} + +export const grepAdapter: Adapter = { + name: "grep", + async init(sessions) { + return { sessions }; + }, + async query(q, state, k) { + const terms = tokenize(q); + const scored: RankedDoc[] = []; + for (const s of state.sessions) { + const body = s.content.toLowerCase(); + let hits = 0; + for (const t of terms) { + if (body.includes(t)) hits += 1; + } + if (hits > 0) { + scored.push({ sessionId: s.id, score: hits }); + } + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k); + }, +}; diff --git a/eval/runner/adapters/vector.ts b/eval/runner/adapters/vector.ts new file mode 100644 index 0000000..c40e414 --- /dev/null +++ b/eval/runner/adapters/vector.ts @@ -0,0 +1,108 @@ +import type { Adapter, RankedDoc, Session } from "../types.js"; + +interface VectorState { + sessions: Session[]; + embeddings: Float32Array[]; +} + +const OPENAI_URL = "https://api.openai.com/v1/embeddings"; +const MODEL = "text-embedding-3-small"; +const DIM = 1536; + +async function embed(text: string, apiKey: string): Promise { + const res = await fetch(OPENAI_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ input: text, model: MODEL }), + }); + if (!res.ok) { + throw new Error(`OpenAI embed failed: ${res.status} ${await res.text()}`); + } + const data = (await res.json()) as { data: Array<{ embedding: number[] }> }; + return Float32Array.from(data.data[0].embedding); +} + +async function embedBatch(texts: string[], apiKey: string): Promise { + const res = await fetch(OPENAI_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ input: texts, model: MODEL }), + }); + if (!res.ok) { + throw new Error(`OpenAI batch embed failed: ${res.status} ${await res.text()}`); + } + const data = (await res.json()) as { data: Array<{ embedding: number[]; index: number }> }; + if (!Array.isArray(data.data) || data.data.length !== texts.length) { + throw new Error( + `OpenAI batch embed: expected ${texts.length} embeddings, got ${data.data?.length ?? 0}`, + ); + } + const out = new Array(texts.length); + for (const row of data.data) { + if ( + !Number.isInteger(row.index) || + row.index < 0 || + row.index >= texts.length || + out[row.index] !== undefined + ) { + throw new Error(`OpenAI batch embed: invalid or duplicate index ${row.index}`); + } + if (!Array.isArray(row.embedding) || row.embedding.length === 0) { + throw new Error(`OpenAI batch embed: empty embedding at index ${row.index}`); + } + out[row.index] = Float32Array.from(row.embedding); + } + return out; +} + +function cosine(a: Float32Array, b: Float32Array): number { + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + const denom = Math.sqrt(na) * Math.sqrt(nb); + return denom === 0 ? 0 : dot / denom; +} + +export const vectorAdapter: Adapter = { + name: "vector", + async init(sessions) { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error("OPENAI_API_KEY required for vector adapter"); + const embeddings: Float32Array[] = new Array(sessions.length); + const BATCH = 50; + for (let i = 0; i < sessions.length; i += BATCH) { + const batch = sessions.slice(i, i + BATCH); + const vecs = await embedBatch( + batch.map((s) => s.content.slice(0, 8000)), + apiKey, + ); + for (let j = 0; j < vecs.length; j++) embeddings[i + j] = vecs[j]; + } + if (embeddings.length > 0 && embeddings[0].length !== DIM) { + throw new Error(`unexpected embedding dim: ${embeddings[0].length}`); + } + return { sessions, embeddings }; + }, + async query(q, state, k) { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error("OPENAI_API_KEY required for vector adapter"); + const qvec = await embed(q, apiKey); + const scored: RankedDoc[] = state.sessions.map((s, i) => ({ + sessionId: s.id, + score: cosine(qvec, state.embeddings[i]), + })); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, k); + }, +}; diff --git a/eval/runner/coding-life.ts b/eval/runner/coding-life.ts new file mode 100644 index 0000000..753ca87 --- /dev/null +++ b/eval/runner/coding-life.ts @@ -0,0 +1,101 @@ +import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { agentmemoryAdapter } from "./adapters/agentmemory.js"; +import { grepAdapter } from "./adapters/grep.js"; +import { vectorAdapter } from "./adapters/vector.js"; +import { aggregate, scoreQuestion } from "./score.js"; +import type { Adapter, Question, ScoreRow, Session } from "./types.js"; + +const ADAPTERS: Record = { + grep: grepAdapter as unknown as Adapter, + vector: vectorAdapter as unknown as Adapter, + agentmemory: agentmemoryAdapter as unknown as Adapter, +}; + +interface CliOptions { + data: string; + adapters: string; + k: string; + out: string; +} + +function parse(): CliOptions { + const { values } = parseArgs({ + options: { + data: { type: "string", default: "eval/data/coding-agent-life-v1" }, + adapters: { type: "string", default: "grep,vector,agentmemory" }, + k: { type: "string", default: "5" }, + out: { type: "string", default: "eval/reports/coding-life" }, + }, + }); + return values as unknown as CliOptions; +} + +async function main(): Promise { + const opts = parse(); + const k = Number(opts.k); + if (!Number.isInteger(k) || k <= 0) { + console.error(`--k must be a positive integer, got: ${opts.k}`); + process.exit(2); + } + const sessions = JSON.parse( + readFileSync(resolve(opts.data, "sessions.json"), "utf8"), + ) as Session[]; + const queriesRaw = JSON.parse( + readFileSync(resolve(opts.data, "queries.json"), "utf8"), + ) as Array>; + const questions: Question[] = queriesRaw.map((q) => ({ ...q, haystack: sessions })); + const adapterNames = opts.adapters.split(",").map((s) => s.trim()).filter(Boolean); + for (const a of adapterNames) { + if (!ADAPTERS[a]) { + console.error(`unknown adapter: ${a}. options: ${Object.keys(ADAPTERS).join(",")}`); + process.exit(2); + } + } + console.log( + `loaded ${sessions.length} sessions, ${questions.length} queries, adapters: ${adapterNames.join(",")}, k=${k}`, + ); + + const outDir = resolve(opts.out); + mkdirSync(outDir, { recursive: true }); + const ndjsonPath = `${outDir}/scores.ndjson`; + if (existsSync(ndjsonPath)) writeFileSync(ndjsonPath, ""); + + const rows: ScoreRow[] = []; + for (const adapterName of adapterNames) { + const adapter = ADAPTERS[adapterName]; + console.log(`\n== ${adapter.name} ==`); + const state = await adapter.init(sessions); + try { + for (const q of questions) { + const t0 = performance.now(); + const ranked = await adapter.query(q.question, state, k); + const latencyMs = performance.now() - t0; + const row = scoreQuestion(q, ranked, k, adapter.name, latencyMs); + rows.push(row); + appendFileSync(ndjsonPath, JSON.stringify(row) + "\n"); + const mark = row.hit ? "+" : "-"; + console.log( + ` ${mark} ${q.id} [${q.type}] R@${k}=${row.recallAtK.toFixed(2)} (${Math.round(latencyMs)}ms)`, + ); + } + } finally { + if (adapter.teardown) await adapter.teardown(state); + } + } + + const agg = aggregate(rows); + writeFileSync(`${outDir}/summary.json`, JSON.stringify(agg, null, 2)); + console.log("\n=== Summary ==="); + for (const [adapter, stats] of Object.entries(agg.byAdapter)) { + console.log( + ` ${adapter.padEnd(22)} P@${k}=${stats.p.toFixed(3)} R@${k}=${stats.r.toFixed(3)} hit=${stats.hit}/${stats.n} p50=${Math.round(stats.latencyP50)}ms`, + ); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/eval/runner/load.ts b/eval/runner/load.ts new file mode 100644 index 0000000..aece245 --- /dev/null +++ b/eval/runner/load.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs"; +import type { Question, Session } from "./types.js"; + +interface LongMemEvalRaw { + question_id: string; + question_type: string; + question: string; + answer?: string; + answer_session_ids: string[]; + haystack_session_ids: string[]; + haystack_sessions: Array>; +} + +function flattenSession(turns: Array<{ role: string; content: string }>): string { + return turns.map((t) => `[${t.role}] ${t.content}`).join("\n\n"); +} + +export function loadLongMemEval(path: string, limit?: number): Question[] { + const raw = JSON.parse(readFileSync(path, "utf8")) as LongMemEvalRaw[]; + const slice = typeof limit === "number" ? raw.slice(0, limit) : raw; + const questions: Question[] = []; + for (const r of slice) { + if (r.haystack_session_ids.length !== r.haystack_sessions.length) { + throw new Error( + `LongMemEval row ${r.question_id}: haystack_session_ids (${r.haystack_session_ids.length}) and haystack_sessions (${r.haystack_sessions.length}) length mismatch`, + ); + } + const haystack: Session[] = r.haystack_session_ids.map((id, i) => ({ + id, + content: flattenSession(r.haystack_sessions[i]), + })); + questions.push({ + id: r.question_id, + type: r.question_type, + question: r.question, + answer: r.answer, + goldSessionIds: r.answer_session_ids, + haystack, + }); + } + return questions; +} + +export function stratifySample(questions: Question[], perType: number): Question[] { + const buckets: Record = {}; + for (const q of questions) { + (buckets[q.type] ??= []).push(q); + } + const out: Question[] = []; + for (const type of Object.keys(buckets).sort()) { + out.push(...buckets[type].slice(0, perType)); + } + return out; +} diff --git a/eval/runner/longmemeval.ts b/eval/runner/longmemeval.ts new file mode 100644 index 0000000..a906fa2 --- /dev/null +++ b/eval/runner/longmemeval.ts @@ -0,0 +1,126 @@ +import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { agentmemoryAdapter } from "./adapters/agentmemory.js"; +import { grepAdapter } from "./adapters/grep.js"; +import { vectorAdapter } from "./adapters/vector.js"; +import { loadLongMemEval, stratifySample } from "./load.js"; +import { aggregate, scoreQuestion } from "./score.js"; +import type { Adapter, ScoreRow } from "./types.js"; + +const ADAPTERS: Record = { + grep: grepAdapter as unknown as Adapter, + vector: vectorAdapter as unknown as Adapter, + agentmemory: agentmemoryAdapter as unknown as Adapter, +}; + +interface CliOptions { + data: string; + adapters: string; + k: string; + limit?: string; + stratify?: string; + out: string; +} + +function parse(): CliOptions { + const { values } = parseArgs({ + options: { + data: { type: "string", default: process.env.LONGMEMEVAL_PATH ?? "" }, + adapters: { type: "string", default: "grep,vector,agentmemory" }, + k: { type: "string", default: "5" }, + limit: { type: "string" }, + stratify: { type: "string" }, + out: { type: "string", default: "eval/reports/longmemeval" }, + }, + }); + return values as unknown as CliOptions; +} + +async function main(): Promise { + const opts = parse(); + if (!opts.data) { + console.error("--data required (or LONGMEMEVAL_PATH env)"); + process.exit(2); + } + const k = Number(opts.k); + if (!Number.isInteger(k) || k <= 0) { + console.error(`--k must be a positive integer, got: ${opts.k}`); + process.exit(2); + } + let limit: number | undefined; + if (opts.limit !== undefined) { + limit = Number(opts.limit); + if (!Number.isInteger(limit) || limit <= 0) { + console.error(`--limit must be a positive integer, got: ${opts.limit}`); + process.exit(2); + } + } + let perType: number | undefined; + if (opts.stratify !== undefined) { + perType = Number(opts.stratify); + if (!Number.isInteger(perType) || perType <= 0) { + console.error(`--stratify must be a positive integer, got: ${opts.stratify}`); + process.exit(2); + } + } + const adapterNames = opts.adapters.split(",").map((s) => s.trim()).filter(Boolean); + for (const a of adapterNames) { + if (!ADAPTERS[a]) { + console.error(`unknown adapter: ${a}. options: ${Object.keys(ADAPTERS).join(",")}`); + process.exit(2); + } + } + let questions = loadLongMemEval(resolve(opts.data), limit); + if (perType) questions = stratifySample(questions, perType); + console.log( + `loaded ${questions.length} questions, adapters: ${adapterNames.join(",")}, k=${k}`, + ); + + const outDir = resolve(opts.out); + mkdirSync(outDir, { recursive: true }); + const ndjsonPath = `${outDir}/scores.ndjson`; + if (existsSync(ndjsonPath)) writeFileSync(ndjsonPath, ""); + mkdirSync(dirname(ndjsonPath), { recursive: true }); + + const rows: ScoreRow[] = []; + for (const adapterName of adapterNames) { + const adapter = ADAPTERS[adapterName]; + console.log(`\n== ${adapter.name} ==`); + for (const q of questions) { + const t0 = performance.now(); + const state = await adapter.init(q.haystack); + try { + const ranked = await adapter.query(q.question, state, k); + const latencyMs = performance.now() - t0; + const row = scoreQuestion(q, ranked, k, adapter.name, latencyMs); + rows.push(row); + appendFileSync(ndjsonPath, JSON.stringify(row) + "\n"); + const mark = row.hit ? "+" : "-"; + console.log( + ` ${mark} ${q.id} [${q.type}] R@${k}=${row.recallAtK.toFixed(2)} (${Math.round(latencyMs)}ms)`, + ); + } finally { + if (adapter.teardown) await adapter.teardown(state); + } + } + } + + const agg = aggregate(rows); + const summaryPath = `${outDir}/summary.json`; + writeFileSync(summaryPath, JSON.stringify(agg, null, 2)); + + console.log("\n=== Summary ==="); + for (const [adapter, stats] of Object.entries(agg.byAdapter)) { + console.log( + ` ${adapter.padEnd(22)} P@${k}=${stats.p.toFixed(3)} R@${k}=${stats.r.toFixed(3)} hit=${stats.hit}/${stats.n} p50=${Math.round(stats.latencyP50)}ms`, + ); + } + console.log(`\nwrote ${ndjsonPath}`); + console.log(`wrote ${summaryPath}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/eval/runner/score.ts b/eval/runner/score.ts new file mode 100644 index 0000000..b21d30c --- /dev/null +++ b/eval/runner/score.ts @@ -0,0 +1,78 @@ +import type { Question, RankedDoc, ScoreRow } from "./types.js"; + +export function scoreQuestion( + q: Question, + ranked: RankedDoc[], + k: number, + adapter: string, + latencyMs: number, +): ScoreRow { + const topK = ranked.slice(0, k).map((r) => r.sessionId); + const gold = new Set(q.goldSessionIds); + const hits = topK.filter((id) => gold.has(id)).length; + const precisionAtK = k > 0 ? hits / k : 0; + const recallAtK = gold.size === 0 ? 0 : hits / gold.size; + const hit = hits > 0; + let topGoldRank: number | null = null; + for (let i = 0; i < ranked.length; i++) { + if (gold.has(ranked[i].sessionId)) { + topGoldRank = i + 1; + break; + } + } + return { + questionId: q.id, + questionType: q.type, + adapter, + k, + precisionAtK, + recallAtK, + hit, + topGoldRank, + latencyMs, + }; +} + +export function aggregate(rows: ScoreRow[]): { + byAdapter: Record; + byType: Record>; +} { + const byAdapter: Record< + string, + { p: number; r: number; hit: number; n: number; latencyP50: number } + > = {}; + const latencies: Record = {}; + for (const r of rows) { + const a = (byAdapter[r.adapter] ??= { p: 0, r: 0, hit: 0, n: 0, latencyP50: 0 }); + a.p += r.precisionAtK; + a.r += r.recallAtK; + a.hit += r.hit ? 1 : 0; + a.n += 1; + (latencies[r.adapter] ??= []).push(r.latencyMs); + } + for (const adapter of Object.keys(byAdapter)) { + const a = byAdapter[adapter]; + a.p = a.p / a.n; + a.r = a.r / a.n; + const sorted = latencies[adapter].slice().sort((x, y) => x - y); + a.latencyP50 = sorted[Math.floor(sorted.length / 2)] ?? 0; + } + const byType: Record> = + {}; + for (const r of rows) { + const t = (byType[r.questionType] ??= {}); + const a = (t[r.adapter] ??= { p: 0, r: 0, hit: 0, n: 0 }); + a.p += r.precisionAtK; + a.r += r.recallAtK; + a.hit += r.hit ? 1 : 0; + a.n += 1; + } + for (const t of Object.keys(byType)) { + for (const adapter of Object.keys(byType[t])) { + const a = byType[t][adapter]; + a.p = a.p / a.n; + a.r = a.r / a.n; + } + } + return { byAdapter, byType }; +} diff --git a/eval/runner/types.ts b/eval/runner/types.ts new file mode 100644 index 0000000..e72a640 --- /dev/null +++ b/eval/runner/types.ts @@ -0,0 +1,38 @@ +export interface Session { + id: string; + timestamp?: string; + content: string; +} + +export interface Question { + id: string; + type: string; + question: string; + answer?: string; + goldSessionIds: string[]; + haystack: Session[]; +} + +export interface RankedDoc { + sessionId: string; + score: number; +} + +export interface Adapter { + name: string; + init(sessions: Session[], config?: Record): Promise; + query(q: string, state: State, k: number): Promise; + teardown?(state: State): Promise; +} + +export interface ScoreRow { + questionId: string; + questionType: string; + adapter: string; + k: number; + precisionAtK: number; + recallAtK: number; + hit: boolean; + topGoldRank: number | null; + latencyMs: number; +} diff --git a/eval/scripts/sandbox.sh b/eval/scripts/sandbox.sh new file mode 100755 index 0000000..5d40233 --- /dev/null +++ b/eval/scripts/sandbox.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# Boot a sandboxed agentmemory + iii-engine on alt ports with a clean data dir, +# so eval runs aren't polluted by (and don't pollute) your real ~/.agentmemory. +# Source it: `source eval/scripts/sandbox.sh` then run eval scripts; +# the sandbox is torn down on EXIT. + +set -euo pipefail + +SANDBOX_ROOT="${SANDBOX_ROOT:-/tmp/agentmemory-eval-sandbox}" +SANDBOX_PORT="${SANDBOX_PORT:-3411}" +SANDBOX_STREAM_PORT="${SANDBOX_STREAM_PORT:-3412}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if ! command -v iii >/dev/null 2>&1; then + echo "iii binary not on PATH. Install pinned version:" + echo " 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" + exit 1 +fi + +iii_ver=$(iii --version 2>&1 | head -1) +if [[ "$iii_ver" != "0.11.2" ]]; then + echo "warning: iii version on PATH is $iii_ver; agentmemory pins 0.11.2" +fi + +if [[ ! -f "$REPO_ROOT/dist/index.mjs" ]]; then + echo "dist/ missing. Run: npm run build" >&2 + exit 1 +fi + +if [[ -z "${SANDBOX_ROOT:-}" || "$SANDBOX_ROOT" == "/" || "$SANDBOX_ROOT" != /tmp/* ]]; then + echo "refusing to wipe SANDBOX_ROOT='$SANDBOX_ROOT' — must be non-empty and under /tmp/" >&2 + exit 1 +fi +rm -rf "$SANDBOX_ROOT" +mkdir -p "$SANDBOX_ROOT/data" "$SANDBOX_ROOT/.agentmemory" + +cat > "$SANDBOX_ROOT/iii-config.yaml" < "$SANDBOX_ROOT/iii.log" 2>&1 & +SANDBOX_PID=$! + +cleanup() { + echo "tearing down sandbox (pid $SANDBOX_PID)" + kill "$SANDBOX_PID" 2>/dev/null || true + sleep 1 + kill -9 "$SANDBOX_PID" 2>/dev/null || true +} +trap cleanup EXIT + +# wait for livez +for i in $(seq 1 30); do + if curl -sS --max-time 1 "http://localhost:$SANDBOX_PORT/agentmemory/livez" 2>/dev/null | grep -q '"status":"ok"'; then + export AGENTMEMORY_BASE_URL="http://localhost:$SANDBOX_PORT" + echo "sandbox ready: $AGENTMEMORY_BASE_URL" + echo " state: $SANDBOX_ROOT/data/" + echo " logs: $SANDBOX_ROOT/iii.log" + return 0 2>/dev/null || exit 0 + fi + sleep 1 +done + +echo "sandbox failed to come up within 30s. last log lines:" >&2 +tail -10 "$SANDBOX_ROOT/iii.log" >&2 +exit 1 diff --git a/examples/python/README.md b/examples/python/README.md new file mode 100644 index 0000000..ea284dd --- /dev/null +++ b/examples/python/README.md @@ -0,0 +1,71 @@ +# Python usage via `iii-sdk` + +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 the WebSocket transport +on `ws://localhost:49134` — no separate REST client needed. + +This example uses the official Python SDK. + +## Install + +```bash +pip install iii-sdk +``` + +## Quickstart + +Start the agentmemory daemon (defaults to `ws://localhost:49134`, REST on +`:3111`): + +```bash +npx -y @agentmemory/agentmemory +``` + +Then from Python: + +```python +from iii import register_worker + +iii = register_worker("ws://localhost:49134") +iii.connect() + +iii.trigger({ + "function_id": "mem::remember", + "payload": { + "project": "demo", + "title": "auth-stack", + "content": "Service uses HMAC bearer tokens; refresh every 24h.", + "concepts": ["auth", "hmac", "refresh"], + }, +}) + +hits = iii.trigger({ + "function_id": "mem::smart-search", + "payload": {"project": "demo", "query": "how do tokens refresh", "limit": 5}, +}) +print(hits) +``` + +## Functions exposed + +| Function id | Purpose | Required payload | +|---|---|---| +| `mem::remember` | Save a memory | `project`, `title`, `content` | +| `mem::observe` | Hook-driven observation ingest | `hookType`, `sessionId`, `project`, `cwd`, `timestamp` | +| `mem::context` | Render context for a session under a token budget | `sessionId`, `project`, optional `budget` | +| `mem::smart-search` | Hybrid BM25 + vector + concept recall | `project`, `query`, optional `limit` | +| `mem::forget` | Delete a memory by id | `id` | + +The HTTP-trigger wrappers under `api::*` (callable via REST on `:3111`) exist +for the same operations if you need to reach the daemon from a host without an +iii runtime. Inside the iii ecosystem, calling the `mem::*` functions directly +is lower latency. + +## Files + +- `quickstart.py` — minimal save-then-search loop. +- `observe_and_recall.py` — observation ingest + context rendering at a token + budget. + +Both scripts assume the daemon is already running. diff --git a/examples/python/observe_and_recall.py b/examples/python/observe_and_recall.py new file mode 100644 index 0000000..4747bf0 --- /dev/null +++ b/examples/python/observe_and_recall.py @@ -0,0 +1,67 @@ +"""Observation ingest + context rendering at a token budget. + +Pattern: send hook-style observations during a coding session, then ask +agentmemory to render the most relevant context back at a fixed token budget. + +Prerequisites: + pip install iii-sdk + npx -y @agentmemory/agentmemory + +Run: + python examples/python/observe_and_recall.py +""" + +from datetime import datetime, timezone +from iii import register_worker + + +SESSION_ID = "py-example-session-001" +PROJECT = "demo" + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def main() -> None: + iii = register_worker("ws://localhost:49134") + iii.connect() + + observations = [ + ("PreToolUse", {"tool": "Bash", "command": "cargo test"}), + ("PostToolUse", {"tool": "Bash", "exit_code": 0}), + ("UserPromptSubmit", {"prompt": "refactor auth middleware to use HMAC"}), + ] + + for hook_type, data in observations: + iii.trigger( + { + "function_id": "mem::observe", + "payload": { + "hookType": hook_type, + "sessionId": SESSION_ID, + "project": PROJECT, + "cwd": "/home/user/service", + "timestamp": now_iso(), + "data": data, + }, + } + ) + + context = iii.trigger( + { + "function_id": "mem::context", + "payload": { + "sessionId": SESSION_ID, + "project": PROJECT, + "budget": 2000, + }, + } + ) + + print(f"Rendered context ({context.get('token_count', 0)} tokens):\n") + print(context.get("text", "")) + + +if __name__ == "__main__": + main() diff --git a/examples/python/quickstart.py b/examples/python/quickstart.py new file mode 100644 index 0000000..56e5ae1 --- /dev/null +++ b/examples/python/quickstart.py @@ -0,0 +1,46 @@ +"""Minimal agentmemory usage via iii-sdk. + +Prerequisites: + pip install iii-sdk + npx -y @agentmemory/agentmemory # daemon at ws://localhost:49134 + +Run: + python examples/python/quickstart.py +""" + +from iii import register_worker + + +def main() -> None: + iii = register_worker("ws://localhost:49134") + iii.connect() + + iii.trigger( + { + "function_id": "mem::remember", + "payload": { + "project": "demo", + "title": "auth-stack", + "content": "Service uses HMAC bearer tokens; refresh every 24h.", + "concepts": ["auth", "hmac", "refresh"], + }, + } + ) + + hits = iii.trigger( + { + "function_id": "mem::smart-search", + "payload": { + "project": "demo", + "query": "how do tokens refresh", + "limit": 5, + }, + } + ) + + for memory in hits.get("results", []): + print(f"[{memory.get('score', 0):.3f}] {memory.get('title')}: {memory.get('content')}") + + +if __name__ == "__main__": + main() diff --git a/iii-config.docker.yaml b/iii-config.docker.yaml new file mode 100644 index 0000000..682236e --- /dev/null +++ b/iii-config.docker.yaml @@ -0,0 +1,53 @@ +workers: + - name: iii-http + config: + port: 3111 + host: 0.0.0.0 + default_timeout: 180000 + cors: + allowed_origins: ["http://localhost:3111", "http://localhost:3113", "http://127.0.0.1:3111", "http://127.0.0.1:3113"] + allowed_methods: [GET, POST, PUT, DELETE, OPTIONS] + - name: iii-state + config: + adapter: + name: kv + config: + store_method: file_based + file_path: /data/state_store.db + - name: iii-queue + config: + adapter: + name: builtin + - name: iii-pubsub + config: + adapter: + name: local + - name: iii-cron + config: + adapter: + name: kv + - name: iii-stream + config: + port: 3112 + host: 0.0.0.0 + adapter: + name: kv + config: + store_method: file_based + file_path: /data/stream_store + - name: iii-observability + config: + enabled: true + service_name: agentmemory + exporter: memory + # See iii-config.yaml for the rationale on 0.1 / console-off (#519). + sampling_ratio: 0.1 + metrics_enabled: true + logs_enabled: true + logs_console_output: false + - name: iii-exec + config: + watch: + - src/**/*.ts + exec: + - node dist/index.mjs diff --git a/iii-config.yaml b/iii-config.yaml new file mode 100644 index 0000000..62892f5 --- /dev/null +++ b/iii-config.yaml @@ -0,0 +1,61 @@ +workers: + - name: iii-http + config: + port: 3111 + host: 127.0.0.1 + default_timeout: 180000 + cors: + allowed_origins: ["http://localhost:3111", "http://localhost:3113", "http://127.0.0.1:3111", "http://127.0.0.1:3113"] + allowed_methods: [GET, POST, PUT, DELETE, OPTIONS] + - name: iii-state + config: + adapter: + name: kv + config: + store_method: file_based + file_path: ./data/state_store.db + - name: iii-queue + config: + adapter: + name: builtin + - name: iii-pubsub + config: + adapter: + name: local + - name: iii-cron + config: + adapter: + name: kv + - name: iii-stream + config: + port: 3112 + host: 127.0.0.1 + adapter: + name: kv + config: + store_method: file_based + file_path: ./data/stream_store + - name: iii-observability + config: + enabled: true + service_name: agentmemory + exporter: memory + # 0.1 instead of 1.0: at full sampling under sustained load the + # log subscriber falls behind, the worker emits a "Log trigger + # subscriber lagged" WARN, and that WARN re-enters the same + # stream the subscriber is failing to drain — a positive + # feedback loop that wrote 137 GB to daemon.log.new on one user + # in a few days (#519). + sampling_ratio: 0.1 + metrics_enabled: true + logs_enabled: true + # Console output is off by default; the feedback path needs the + # console layer to amplify. Re-enable per-session via env if you + # need verbose tracing for debugging. + logs_console_output: false + - name: iii-exec + config: + watch: + - src/**/*.ts + exec: + - node dist/index.mjs diff --git a/integrations/filesystem-watcher/README.md b/integrations/filesystem-watcher/README.md new file mode 100644 index 0000000..12b11de --- /dev/null +++ b/integrations/filesystem-watcher/README.md @@ -0,0 +1,63 @@ +# @agentmemory/fs-watcher + +Filesystem connector for agentmemory. Watches one or more directories and emits an observation to the running agentmemory server every time a file changes. + +Part of the data-source-connectors effort tracked in issue #62. + +## Install + +```bash +npm install -g @agentmemory/fs-watcher +``` + +Or run without installing: + +```bash +npx @agentmemory/fs-watcher ~/work/my-repo +``` + +## Usage + +```bash +# CLI args win over env. +agentmemory-fs-watcher ~/work/my-repo ~/notes + +# Or set env once in your shell. +export AGENTMEMORY_FS_WATCH_DIRS=~/work/my-repo,~/notes +export AGENTMEMORY_URL=http://localhost:3111 +export AGENTMEMORY_SECRET=... # only if the server requires auth +agentmemory-fs-watcher +``` + +Every file change inside the watched roots becomes a `post_tool_use` observation whose `data.changeKind` is `file_change` or `file_delete`. The first 4 KB of each text file is included as `data.content` so retrieval can match by substring; larger files are truncated with `data.truncated: true`. Binary files are not read (set `AGENTMEMORY_FS_WATCH_ALLOW_BINARY=1` to override). + +Session id and project are required by the observe endpoint — set them via env, or the watcher generates a per-process `fs-watcher--` session id and uses the first root's directory name as the project. + +Requires Node.js **>=20 LTS**. Recursive `fs.watch` needs Node 19.1.0+ on Linux; Node 20 is the minimum supported LTS line. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `AGENTMEMORY_FS_WATCH_DIRS` | — | Comma-separated list of directories to watch | +| `AGENTMEMORY_FS_WATCH_IGNORE` | — | Comma-separated regex patterns to ignore (applied to relative paths) | +| `AGENTMEMORY_FS_WATCH_ALLOW_BINARY` | `0` | `1` to include binary files in the preview read | +| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL | +| `AGENTMEMORY_SECRET` | — | Bearer token, required if the server has `AGENTMEMORY_SECRET` set | +| `AGENTMEMORY_PROJECT` | — | Optional project label attached to each observation | +| `AGENTMEMORY_SESSION_ID` | — | Optional session id to attribute observations to | + +## Defaults + +Ignored out of the box: `.git/`, `node_modules/`, `dist/`, `build/`, `.next/`, `.turbo/`, `coverage/`, `.DS_Store`, `*.log`, `*.lock`. Extend with `AGENTMEMORY_FS_WATCH_IGNORE`. + +Text extensions read for preview: common source, config, and docs (`.ts/.js/.py/.go/.rs/.md/.yaml/...`). Unknown extensions are recorded as a path-only observation without content. + +Writes are debounced 500 ms per path so a stream of saves from your editor becomes a single observation. + +## Notes + +- Uses Node's built-in `fs.watch` with `{ recursive: true }`. Works natively on macOS, Linux, and Windows 10+. No native deps. +- If `fs.watch` errors on a specific root (permission, platform quirk), the watcher logs and continues on the others. +- The process must keep running. Use a process manager (`launchd`, `systemd`, `pm2`) to supervise it. +- This connector is intentionally one-way: it writes observations and never reads the agentmemory store. diff --git a/integrations/filesystem-watcher/bin.mjs b/integrations/filesystem-watcher/bin.mjs new file mode 100755 index 0000000..34ebed7 --- /dev/null +++ b/integrations/filesystem-watcher/bin.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { FilesystemWatcher, configFromEnv } from "./watcher.mjs"; + +const cliArgs = process.argv.slice(2); +const envCfg = configFromEnv(process.env); + +const roots = cliArgs.length > 0 ? cliArgs : envCfg.roots; +if (!roots || roots.length === 0) { + process.stderr.write( + "agentmemory-fs-watcher: no directories to watch.\n" + + "Usage: agentmemory-fs-watcher [...]\n" + + "Or set AGENTMEMORY_FS_WATCH_DIRS=path1,path2\n", + ); + process.exit(2); +} + +const watcher = new FilesystemWatcher({ ...envCfg, roots }); +watcher.start(); +process.stderr.write( + `[fs-watcher] emitting to ${envCfg.baseUrl || "http://localhost:3111"}\n`, +); + +const shutdown = () => { + watcher.stop(); + process.exit(0); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/integrations/filesystem-watcher/package.json b/integrations/filesystem-watcher/package.json new file mode 100644 index 0000000..b260ab6 --- /dev/null +++ b/integrations/filesystem-watcher/package.json @@ -0,0 +1,22 @@ +{ + "name": "@agentmemory/fs-watcher", + "version": "0.1.0", + "description": "Filesystem connector for agentmemory — emits observations on file changes.", + "type": "module", + "bin": { + "agentmemory-fs-watcher": "./bin.mjs" + }, + "main": "./watcher.mjs", + "exports": { + ".": "./watcher.mjs" + }, + "files": ["watcher.mjs", "bin.mjs", "README.md"], + "engines": { "node": ">=20" }, + "license": "Apache-2.0", + "homepage": "https://github.com/rohitg00/agentmemory/tree/main/integrations/filesystem-watcher", + "repository": { + "type": "git", + "url": "https://github.com/rohitg00/agentmemory.git", + "directory": "integrations/filesystem-watcher" + } +} diff --git a/integrations/filesystem-watcher/watcher.mjs b/integrations/filesystem-watcher/watcher.mjs new file mode 100644 index 0000000..d68b2b8 --- /dev/null +++ b/integrations/filesystem-watcher/watcher.mjs @@ -0,0 +1,317 @@ +import { watch, promises as fsp, statSync } from "node:fs"; +import { resolve, relative, join, extname, sep, basename } from "node:path"; +import { randomBytes } from "node:crypto"; + +const TEXT_EXTENSIONS = new Set([ + ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", + ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift", + ".c", ".cc", ".cpp", ".h", ".hpp", + ".md", ".mdx", ".txt", ".rst", + ".json", ".yaml", ".yml", ".toml", ".ini", ".env", + ".html", ".css", ".scss", ".vue", ".svelte", + ".sh", ".bash", ".zsh", ".fish", + ".sql", ".graphql", ".proto", +]); + +const DEFAULT_IGNORE = [ + /(?:^|\/)\.git(?:\/|$)/, + /(?:^|\/)node_modules(?:\/|$)/, + /(?:^|\/)dist(?:\/|$)/, + /(?:^|\/)build(?:\/|$)/, + /(?:^|\/)\.next(?:\/|$)/, + /(?:^|\/)\.turbo(?:\/|$)/, + /(?:^|\/)coverage(?:\/|$)/, + /(?:^|\/)\.DS_Store$/, + /\.log$/, + /\.lock$/, +]; + +const MAX_PREVIEW_BYTES = 4096; +const DEBOUNCE_MS = 500; +const REDACTED = "[REDACTED]"; +const PEM_BEGIN_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/; +const PEM_END_RE = /-----END [A-Z ]*PRIVATE KEY-----/; +const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g; +const JWT_MIN_LEN = 100; + +function isDotEnvPath(path) { + const name = basename(path).toLowerCase(); + return name === ".env" || name.startsWith(".env."); +} + +function isSensitiveKey(key) { + const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase(); + return [ + "apikey", + "accesstoken", + "accesskey", + "authorization", + "bearer", + "clientsecret", + "password", + "passwd", + "privatekey", + "pwd", + "secret", + "token", + ].some((needle) => normalized.includes(needle)); +} + +function redactJwtTokens(line) { + return line.replace(JWT_RE, (match) => (match.length >= JWT_MIN_LEN ? REDACTED : match)); +} + +function redactSensitiveLine(line) { + if (PEM_BEGIN_RE.test(line) || PEM_END_RE.test(line)) { + return line; + } + const assignment = line.match( + /^(\s*(?:export\s+)?["']?([A-Za-z_][A-Za-z0-9_.-]*)["']?\s*([=:])\s*)(.*)$/, + ); + if (assignment && isSensitiveKey(assignment[2])) { + const bearer = assignment[3] === ":" ? assignment[4].match(/^(Bearer\s+).+/i) : null; + return `${assignment[1]}${bearer ? bearer[1] : ""}${REDACTED}`; + } + const bearerRedacted = line.replace( + /\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/gi, + `$1${REDACTED}`, + ); + return redactJwtTokens(bearerRedacted); +} + +function redactPemBlocks(preview) { + const lines = preview.split("\n"); + const out = []; + let inBlock = false; + for (const line of lines) { + if (!inBlock) { + const beginMatch = line.match(PEM_BEGIN_RE); + if (!beginMatch) { + out.push(line); + continue; + } + const beginIdx = beginMatch.index; + const endMatch = line.match(PEM_END_RE); + if (endMatch && endMatch.index > beginIdx) { + const before = line.slice(0, beginIdx); + const after = line.slice(endMatch.index + endMatch[0].length); + out.push(`${before}${beginMatch[0]}${REDACTED}${endMatch[0]}${after}`); + } else { + out.push(`${line.slice(0, beginIdx)}${beginMatch[0]}`); + out.push(REDACTED); + inBlock = true; + } + } else { + const endMatch = line.match(PEM_END_RE); + if (endMatch) { + out.push(`${endMatch[0]}${line.slice(endMatch.index + endMatch[0].length)}`); + inBlock = false; + } + } + } + return out.join("\n"); +} + +function redactSensitivePreview(preview) { + return redactPemBlocks(preview).split("\n").map(redactSensitiveLine).join("\n"); +} + +export class FilesystemWatcher { + constructor(config = {}) { + this.roots = (config.roots || []).map((r) => resolve(r)); + this.baseUrl = (config.baseUrl || "http://localhost:3111").replace(/\/+$/, ""); + this.secret = config.secret; + this.project = + config.project || + (this.roots[0] ? basename(this.roots[0]) : "filesystem-watcher"); + this.sessionId = + config.sessionId || + `fs-watcher-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`; + this.ignore = [...DEFAULT_IGNORE, ...(config.ignorePatterns || [])]; + this.allowBinary = Boolean(config.allowBinary); + this.logger = config.logger || console; + this.watchers = []; + this.pendingByPath = new Map(); + } + + isIgnored(path) { + return this.ignore.some((re) => re.test(path)); + } + + isTextFile(path) { + if (this.allowBinary) return true; + const ext = extname(path).toLowerCase(); + return TEXT_EXTENSIONS.has(ext) || isDotEnvPath(path); + } + + async readPreview(path) { + try { + const fh = await fsp.open(path, "r"); + try { + const buf = Buffer.alloc(MAX_PREVIEW_BYTES); + const { bytesRead } = await fh.read(buf, 0, MAX_PREVIEW_BYTES, 0); + return buf.slice(0, bytesRead).toString("utf-8"); + } finally { + await fh.close(); + } + } catch { + return null; + } + } + + async emit(event) { + const headers = { "content-type": "application/json" }; + if (this.secret) headers.authorization = `Bearer ${this.secret}`; + try { + const res = await fetch(`${this.baseUrl}/agentmemory/observe`, { + method: "POST", + headers, + body: JSON.stringify(event), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + this.logger.warn?.( + `[fs-watcher] observe ${res.status}: ${await res.text().catch(() => "")}`, + ); + } + } catch (err) { + this.logger.warn?.(`[fs-watcher] observe failed: ${err?.message || err}`); + } + } + + schedule(rootDir, relPath) { + const key = join(rootDir, relPath); + const existing = this.pendingByPath.get(key); + if (existing) clearTimeout(existing.timer); + const timer = setTimeout(() => { + this.pendingByPath.delete(key); + this.flush(rootDir, relPath).catch((err) => + this.logger.warn?.(`[fs-watcher] flush failed: ${err?.message || err}`), + ); + }, DEBOUNCE_MS); + this.pendingByPath.set(key, { timer }); + } + + async flush(rootDir, relPath) { + const absPath = join(rootDir, relPath); + if (this.isIgnored(relPath)) return; + let exists = true; + let size = 0; + try { + const st = statSync(absPath); + if (!st.isFile()) return; + size = st.size; + } catch { + exists = false; + } + const changeKind = exists ? "file_change" : "file_delete"; + let preview = null; + if (exists && this.isTextFile(absPath)) { + preview = await this.readPreview(absPath); + if (preview !== null) preview = redactSensitivePreview(preview); + } + const truncated = exists && size > MAX_PREVIEW_BYTES; + const payload = { + hookType: "post_tool_use", + sessionId: this.sessionId, + project: this.project, + cwd: rootDir, + timestamp: new Date().toISOString(), + data: { + source: "filesystem-watcher", + changeKind, + files: [relPath], + content: this.formatContent(relPath, changeKind, preview, { + size, + truncated, + }), + rootDir, + absPath, + size, + truncated, + }, + }; + await this.emit(payload); + } + + formatContent(relPath, changeKind, preview, { size, truncated }) { + if (changeKind === "file_delete") return `deleted: ${relPath}`; + const head = `${relPath} (${size} bytes${truncated ? ", truncated" : ""})`; + if (preview === null) return head; + return `${head}\n\n${preview}`; + } + + start() { + if (this.roots.length === 0) { + throw new Error("filesystem-watcher: at least one root directory is required"); + } + const failures = []; + for (const root of this.roots) { + try { + const handle = watch( + root, + { recursive: true, persistent: true }, + (_eventType, filename) => { + if (!filename) return; + const rel = filename.split(sep).join("/"); + if (this.isIgnored(rel)) return; + this.schedule(root, rel); + }, + ); + handle.on("error", (err) => { + this.logger.warn?.(`[fs-watcher] watch error on ${root}: ${err?.message || err}`); + }); + this.watchers.push(handle); + this.logger.info?.(`[fs-watcher] watching ${root}`); + } catch (err) { + const msg = err?.message || String(err); + failures.push(`${root}: ${msg}`); + this.logger.error?.(`[fs-watcher] failed to watch ${root}: ${msg}`); + } + } + if (this.watchers.length === 0) { + throw new Error( + `filesystem-watcher: could not watch any of the configured roots. ` + + `If you are on Node 18 + Linux, recursive fs.watch requires Node >=19.1.0; upgrade to Node 20 LTS or newer. ` + + `Failures: ${failures.join("; ")}`, + ); + } + } + + stop() { + for (const w of this.watchers) { + try { + w.close(); + } catch {} + } + this.watchers = []; + for (const { timer } of this.pendingByPath.values()) { + clearTimeout(timer); + } + this.pendingByPath.clear(); + } +} + +// Small helper used by tests and bin.mjs to parse env. +export function configFromEnv(env = process.env) { + const roots = (env.AGENTMEMORY_FS_WATCH_DIRS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const extraIgnore = (env.AGENTMEMORY_FS_WATCH_IGNORE || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => new RegExp(s)); + return { + roots, + baseUrl: env.AGENTMEMORY_URL, + secret: env.AGENTMEMORY_SECRET, + project: env.AGENTMEMORY_PROJECT || null, + sessionId: env.AGENTMEMORY_SESSION_ID || null, + ignorePatterns: extraIgnore, + allowBinary: env.AGENTMEMORY_FS_WATCH_ALLOW_BINARY === "1", + }; +} + +export { relative as _relativeForTests }; diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md new file mode 100644 index 0000000..ba06c10 --- /dev/null +++ b/integrations/hermes/README.md @@ -0,0 +1,130 @@ +

+ agentmemory +

+ +

+ Hermes Agent +  agentmemory for Hermes Agent +

+ +

+ Your Hermes agent remembers everything. No more re-explaining.
+ Persistent cross-session memory via agentmemory — 95.2% retrieval accuracy on LongMemEval-S. Cross-agent shared with Claude Code, Cursor, OpenCode, and more. +

+ +

+ 43 MCP tools + 6 lifecycle hooks + 95.2% R@5 + Self-hosted + Apache 2.0 +

+ +--- + +## Install it in 30 seconds + +**Paste this prompt into Hermes** and it does the whole setup for you: + +```text +Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a +separate terminal to start the memory server on localhost:3111. Then +add this to `~/.hermes/config.yaml` so Hermes can use agentmemory as +an MCP server with all 43 memory tools: + +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory + +Verify it's working with +`curl http://localhost:3111/agentmemory/health` — it should return +{"status":"healthy"}. Open the real-time viewer at +http://localhost:3113 to watch memories being captured live. + +If I want deeper integration — pre-LLM context injection, turn-level +capture, memory-write mirroring to MEMORY.md, and system prompt block +injection — copy `integrations/hermes` from the agentmemory repo to +`~/.hermes/plugins/agentmemory` instead. That gives me the +6-hook memory provider plugin on top of the MCP server. +``` + +That's it. Hermes handles the rest. + +## Quick setup + +### Option 1: MCP server (zero code) + +Add to `~/.hermes/config.yaml`: + +```yaml +mcp_servers: + agentmemory: + command: npx + args: ["-y", "@agentmemory/mcp"] + +memory: + provider: agentmemory +``` + +This gives Hermes access to all 43 MCP tools and enables the agentmemory memory provider. Start the server separately: + +```bash +npx @agentmemory/agentmemory +``` + +### Option 2: Memory provider plugin (deeper integration) + +Copy this folder to your Hermes plugins directory: + +```bash +cp -r integrations/hermes ~/.hermes/plugins/agentmemory +``` + +Start the agentmemory server: + +```bash +npx @agentmemory/agentmemory +``` + +The plugin auto-detects the running server and hooks into the Hermes agent loop. Make sure `memory.provider` is set to `agentmemory` in `~/.hermes/config.yaml`: + +- `prefetch()` injects relevant memories before each LLM call +- `sync_turn()` captures every conversation turn in the background +- `on_session_end()` marks sessions complete for summarization +- `on_pre_compress()` re-injects context before compaction +- `on_memory_write()` mirrors MEMORY.md writes to agentmemory +- `system_prompt_block()` injects project profile at session start + +### Environment variables + +| Variable | Default | Description | +|---|---|---| +| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL | +| `AGENTMEMORY_SECRET` | (none) | Auth token for protected instances | +| `AGENTMEMORY_REQUIRE_HTTPS` | (off) | When set to `1`, refuse to send the bearer token over plaintext HTTP to a non-loopback host. Sends only when `AGENTMEMORY_URL` is `https://...` or points at `localhost`/`127.0.0.1`/`::1`. With this off, the plugin warns once on stderr but still sends. | + +The plugin reads `~/.agentmemory/.env` (or `$XDG_CONFIG_HOME/agentmemory/.env`) at import time and populates any missing values into the process environment via `os.environ.setdefault`. Anything you set in the shell takes precedence; the file is only used to fill gaps. This means `hermes memory status` reports the plugin as available even when the agentmemory service is launched by systemd or another process manager that loads `~/.agentmemory/.env` directly without exporting it to the Hermes CLI shell (#250). + +## What Hermes gets + +- 95.2% retrieval accuracy (LongMemEval-S, ICLR 2025) +- Hybrid search: BM25 + vector + knowledge graph +- Memory versioning, decay, and auto-forget +- Cross-agent: memories from Claude Code, Cursor, Gemini CLI all accessible +- Real-time viewer at http://localhost:3113 + +## How it works + +Hermes has two memory files (MEMORY.md, USER.md) and SQLite full-text search. agentmemory adds structured memory on top: + +| Hermes built-in | agentmemory adds | +|---|---| +| MEMORY.md (flat text) | Structured observations with facts, concepts, files | +| USER.md (preferences) | Project profiles with top patterns and conventions | +| SQLite FTS5 (session search) | BM25 + vector + knowledge graph (95.2% R@5) | +| Skills (self-improving) | Skill extraction from completed sessions | +| Single agent | Cross-agent memory via MCP + REST | diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py new file mode 100644 index 0000000..2933632 --- /dev/null +++ b/integrations/hermes/__init__.py @@ -0,0 +1,388 @@ +""" +agentmemory memory provider for Hermes Agent. + +Drop this folder into ~/.hermes/plugins/agentmemory/ +or install via: hermes plugin install agentmemory + +Requires agentmemory server running: npx @agentmemory/agentmemory +""" + +from __future__ import annotations + +import json +import os +import sys +import threading +import time +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse +from urllib.request import Request, urlopen +from urllib.error import URLError + +try: + from agent.memory_provider import MemoryProvider +except ImportError: + from abc import ABC, abstractmethod + + class MemoryProvider(ABC): + @property + @abstractmethod + def name(self) -> str: ... + @abstractmethod + def is_available(self) -> bool: ... + @abstractmethod + def initialize(self, session_id: str, **kwargs: Any) -> None: ... + @abstractmethod + def get_tool_schemas(self) -> list[dict]: ... + @abstractmethod + def handle_tool_call(self, name: str, args: dict) -> str: ... + def get_config_schema(self) -> list[dict]: return [] + def save_config(self, values: dict, hermes_home: str) -> None: pass + def system_prompt_block(self) -> str: return "" + def prefetch(self, query: str, **kwargs: Any) -> str: return "" + def queue_prefetch(self, query: str, **kwargs: Any) -> None: pass + def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: pass + def on_session_end(self, messages: list, **kwargs: Any) -> None: pass + def on_pre_compress(self, messages: list, **kwargs: Any) -> None: pass + def on_memory_write(self, action: str, target: str, content: str, **kwargs: Any) -> None: pass + def shutdown(self, **kwargs: Any) -> None: pass + + +DEFAULT_BASE_URL = "http://localhost:3111" +TIMEOUT = 5 +LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} +_plaintext_bearer_warned = False + +# agentmemory's documented runtime config lives at ~/.agentmemory/.env. +# When agentmemory is launched as a systemd user service (or any other +# process manager that loads that file directly), those values never +# reach an interactive shell. `hermes memory status` then reads +# os.environ in the Hermes CLI process, finds AGENTMEMORY_URL / +# AGENTMEMORY_SECRET unset, and reports the plugin as "Missing" even +# though the service is healthy and live sessions can use it (#250). +# +# Preload the file at plugin-import time using os.environ.setdefault so +# we never override anything the user explicitly set in the shell. The +# preload is best-effort and silent on any failure (file absent, +# unreadable, malformed) — the plugin falls back to its existing default +# (http://localhost:3111) and Hermes status reflects that. +def _preload_agentmemory_dotenv() -> None: + candidates: list[Path] = [] + home = os.environ.get("HOME") + if home: + candidates.append(Path(home) / ".agentmemory" / ".env") + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + candidates.append(Path(xdg_config) / "agentmemory" / ".env") + for path in candidates: + try: + if not path.is_file(): + continue + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key: + os.environ.setdefault(key, value) + except (OSError, UnicodeDecodeError): + continue + # Guarantee AGENTMEMORY_URL is set so `hermes memory status` never + # reports it as Missing when a user runs agentmemory at the default + # localhost:3111 (or via systemd with the URL line commented out in + # ~/.agentmemory/.env because it matches the default). #520. + os.environ.setdefault("AGENTMEMORY_URL", DEFAULT_BASE_URL) + + +_preload_agentmemory_dotenv() + + +def _validate_url(base: str) -> bool: + if not base: + return False + try: + parsed = urlparse(base) + # .port raises ValueError on a non-numeric or out-of-range port + _ = parsed.port + except ValueError: + return False + if parsed.scheme not in ("http", "https"): + return False + return bool(parsed.hostname) + + +def _uses_plaintext_bearer_auth(base: str, secret: str = "") -> bool: + if not secret: + return False + parsed = urlparse(base) + return parsed.scheme == "http" and (parsed.hostname or "").lower() not in LOOPBACK_HOSTS + + +def _plaintext_bearer_auth_message(base: str) -> str: + return f"agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to {base}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel." + + +def _warn_plaintext_bearer_auth(message: str) -> None: + print(message, file=sys.stderr) + + +def _check_plaintext_bearer_guard( + base: str, + secret: str = "", + warn: Callable[[str], None] | None = None, +) -> None: + global _plaintext_bearer_warned + if not _uses_plaintext_bearer_auth(base, secret): + return + message = _plaintext_bearer_auth_message(base) + if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1": + raise RuntimeError(message) + if not _plaintext_bearer_warned: + _plaintext_bearer_warned = True + (warn or _warn_plaintext_bearer_auth)(message) + + +def _reset_plaintext_bearer_guard_for_tests() -> None: + global _plaintext_bearer_warned + _plaintext_bearer_warned = False + + +def _api(base: str, path: str, body: dict | None = None, method: str = "POST", secret: str = "") -> dict | None: + if not _validate_url(base): + return None + url = f"{base}/agentmemory/{path}" + headers = {"Content-Type": "application/json"} + auth = secret or os.environ.get("AGENTMEMORY_SECRET", "") + _check_plaintext_bearer_guard(base, auth) + if auth: + headers["Authorization"] = f"Bearer {auth}" + + data = json.dumps(body).encode() if body else None + req = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(req, timeout=TIMEOUT) as resp: + return json.loads(resp.read().decode()) + except (URLError, TimeoutError, json.JSONDecodeError): + return None + + +def _api_bg(base: str, path: str, body: dict | None = None) -> None: + t = threading.Thread(target=_api, args=(base, path, body), daemon=True) + t.start() + + +class AgentMemoryProvider(MemoryProvider): + + @property + def name(self) -> str: + return "agentmemory" + + def is_available(self) -> bool: + # Hermes contract: no network calls in is_available. + base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL) + return _validate_url(base) + + def initialize(self, session_id: str, **kwargs: Any) -> None: + self._base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL) + self._session_id = session_id + self._project = kwargs.get("cwd", os.getcwd()) + if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1": + _check_plaintext_bearer_guard(self._base, os.environ.get("AGENTMEMORY_SECRET", "")) + + _api(self._base, "session/start", { + "sessionId": session_id, + "project": self._project, + "cwd": self._project, + }) + + def get_config_schema(self) -> list[dict]: + return [ + { + "key": "url", + "description": "agentmemory server URL", + "default": DEFAULT_BASE_URL, + "env_var": "AGENTMEMORY_URL", + }, + { + "key": "secret", + "description": "agentmemory auth secret (optional)", + "secret": True, + "required": False, + "env_var": "AGENTMEMORY_SECRET", + }, + ] + + def save_config(self, values: dict, hermes_home: str) -> None: + config_path = Path(hermes_home) / "agentmemory.json" + config_path.write_text(json.dumps(values, indent=2)) + + def system_prompt_block(self) -> str: + result = _api(self._base, "context", { + "sessionId": self._session_id, + "project": self._project, + }) + if result and result.get("context"): + return result["context"] + return "" + + def prefetch(self, query: str, **kwargs: Any) -> str: + result = _api(self._base, "smart-search", { + "query": query, + "limit": 5, + }) + if not result or not result.get("results"): + return "" + + lines = [] + for r in result["results"][:5]: + obs = r.get("observation", r) + title = obs.get("title", "") + narrative = obs.get("narrative", "") + if title: + lines.append(f"- {title}: {narrative[:200]}") + return "\n".join(lines) if lines else "" + + def queue_prefetch(self, query: str, **kwargs: Any) -> None: + _api_bg(self._base, "smart-search", {"query": query, "limit": 3}) + + def get_tool_schemas(self) -> list[dict]: + return [ + { + "name": "memory_recall", + "description": "Search agentmemory for past observations by keyword", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "limit": {"type": "integer", "description": "Max results", "default": 10}, + }, + "required": ["query"], + }, + }, + { + "name": "memory_save", + "description": "Save an insight, decision, or pattern to long-term memory", + "parameters": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "What to remember"}, + "type": { + "type": "string", + "enum": ["pattern", "preference", "architecture", "bug", "workflow", "fact"], + "description": "Memory type", + }, + }, + "required": ["content"], + }, + }, + { + "name": "memory_search", + "description": "Hybrid semantic + keyword search across all memories", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 5}, + }, + "required": ["query"], + }, + }, + ] + + def handle_tool_call(self, name: str, args: dict) -> str: + # Hermes stores the return value as the tool result `content` in the + # session history. Anthropic-protocol providers reject non-string + # content with a 400 on the next request, so always serialize to a + # JSON string here — matches what agentmemory's main MCP server does + # in src/mcp/standalone.ts (`{ type: "text", text: JSON.stringify(...) }`). + if name == "memory_recall": + result = _api(self._base, "search", { + "query": args["query"], + "limit": args.get("limit", 10), + }) + if not result: + return json.dumps({"results": []}) + items = [] + for r in result.get("results", []): + obs = r.get("observation", r) + items.append({ + "title": obs.get("title", ""), + "type": obs.get("type", ""), + "narrative": obs.get("narrative", ""), + "importance": obs.get("importance", 0), + "timestamp": obs.get("timestamp", ""), + }) + return json.dumps({"results": items}) + + if name == "memory_save": + result = _api(self._base, "remember", { + "content": args["content"], + "type": args.get("type", "fact"), + }) + return json.dumps(result or {"success": False}) + + if name == "memory_search": + result = _api(self._base, "smart-search", { + "query": args["query"], + "limit": args.get("limit", 5), + }) + if not result: + return json.dumps({"results": []}) + items = [] + for r in result.get("results", []): + obs = r.get("observation", r) + items.append({ + "title": obs.get("title", ""), + "narrative": obs.get("narrative", "")[:300], + "score": r.get("combinedScore", r.get("score", 0)), + }) + return json.dumps({"results": items}) + + return json.dumps({"error": f"Unknown tool: {name}"}) + + def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: + _api_bg(self._base, "observe", { + "hookType": "post_tool_use", + "sessionId": kwargs.get("session_id", self._session_id), + "project": self._project, + "cwd": self._project, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "data": { + "tool_name": "conversation", + "tool_input": user[:500], + "tool_output": assistant[:2000], + }, + }) + + def on_session_end(self, messages: list, **kwargs: Any) -> None: + _api(self._base, "session/end", { + "sessionId": kwargs.get("session_id", self._session_id), + }) + + def on_pre_compress(self, messages: list, **kwargs: Any) -> None: + result = _api(self._base, "context", { + "sessionId": kwargs.get("session_id", self._session_id), + "project": self._project, + }) + if result and result.get("context"): + messages.insert(0, { + "role": "user", + "content": f"[agentmemory context before compaction]\n{result['context']}", + }) + + def on_memory_write(self, action: str, target: str, content: str, **kwargs: Any) -> None: + if action in ("add", "update") and content: + _api_bg(self._base, "remember", { + "content": content, + "type": "fact", + }) + + def shutdown(self, **kwargs: Any) -> None: + pass + + +def register(ctx: Any) -> None: + ctx.register_memory_provider(AgentMemoryProvider()) diff --git a/integrations/hermes/plugin.yaml b/integrations/hermes/plugin.yaml new file mode 100644 index 0000000..9ea5cb9 --- /dev/null +++ b/integrations/hermes/plugin.yaml @@ -0,0 +1,12 @@ +name: agentmemory +version: 0.8.0 +description: "Persistent cross-session memory for Hermes Agent via agentmemory. 95.2% retrieval accuracy on LongMemEval." +author: "Rohit Ghumare" +homepage: "https://github.com/rohitg00/agentmemory" +hooks: + - prefetch + - sync_turn + - on_session_end + - on_pre_compress + - on_memory_write + - system_prompt_block diff --git a/integrations/openclaw/README.md b/integrations/openclaw/README.md new file mode 100644 index 0000000..1fe774a --- /dev/null +++ b/integrations/openclaw/README.md @@ -0,0 +1,155 @@ +

+ agentmemory +

+ +

+ OpenClaw +  agentmemory for OpenClaw +

+ +

+ Your OpenClaw agents remember everything. No more re-explaining.
+ Persistent cross-session memory via agentmemory — 95.2% retrieval accuracy on LongMemEval-S. +

+ +

+ 43 MCP tools + OpenClaw memory plugin + 95.2% R@5 + Self-hosted + Apache 2.0 +

+ +--- + +## Install it in 30 seconds + +**Paste this prompt into OpenClaw** and it does the whole setup for you: + +```text +Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111. + +For zero-code setup, add this MCP server so OpenClaw gets all 43 memory tools: + +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"] + } + } +} + +For deeper memory integration, copy `integrations/openclaw` from the agentmemory repo to `~/.openclaw/extensions/agentmemory`, then enable it in `~/.openclaw/openclaw.json`: + +{ + "plugins": { + "slots": { + "memory": "agentmemory" + }, + "entries": { + "agentmemory": { + "enabled": true, + "config": { + "base_url": "http://localhost:3111", + "token_budget": 2000, + "min_confidence": 0.5, + "fallback_on_error": true, + "timeout_ms": 5000 + } + } + } + } +} + +Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer. +``` + +That's it. OpenClaw handles the rest. + +## Option 1: MCP server (zero code) + +Start the agentmemory server in a separate terminal: + +```bash +npx @agentmemory/agentmemory +``` + +Then add to your OpenClaw MCP config: + +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"] + } + } +} +``` + +OpenClaw now has access to all 43 MCP tools including `memory_recall`, `memory_save`, `memory_smart_search`, `memory_timeline`, `memory_profile`, and more. + +## Option 2: OpenClaw memory plugin (deeper integration) + +Copy this folder into OpenClaw's extension directory: + +```bash +mkdir -p ~/.openclaw/extensions +cp -r integrations/openclaw ~/.openclaw/extensions/agentmemory +``` + +Then enable it in `~/.openclaw/openclaw.json`: + +```json +{ + "plugins": { + "slots": { + "memory": "agentmemory" + }, + "entries": { + "agentmemory": { + "enabled": true, + "config": { + "base_url": "http://localhost:3111", + "token_budget": 2000, + "min_confidence": 0.5, + "fallback_on_error": true, + "timeout_ms": 5000 + } + } + } + } +} +``` + +What the plugin does: + +- claims the `plugins.slots.memory = "agentmemory"` slot via `api.registerMemoryCapability({ promptBuilder })` so OpenClaw recognises it as the active memory plugin +- recalls relevant long-term memory before the agent starts (via the `before_agent_start` hook) +- captures completed conversation turns after the agent finishes (via the `agent_end` hook) +- shares the same backend with Claude Code, Codex CLI, Gemini CLI, Hermes, pi, and other agents + +### Memory runtime (current scope) + +The plugin currently registers a `promptBuilder` only — not a full `MemoryPluginRuntime` adapter. OpenClaw's `MemoryRuntimeBackendConfig` type today is `{ backend: "builtin" }` or `{ backend: "qmd" }`; both are openclaw-internal backends that don't fit agentmemory's external REST shape. The hook-driven recall + capture flow above is the working integration path. If you need OpenClaw's in-process memory-runtime APIs (e.g. `getMemorySearchManager`) backed by agentmemory, file an upstream request against `openclaw` for an `"external"` backend type and we'll wire `runtime` here once the contract supports it. + +## Troubleshooting + +**Plugin validates but does not load** — make sure the folder contains `package.json`, `openclaw.plugin.json`, and `plugin.mjs`, and that `plugins.slots.memory` is set to `agentmemory`. + +**`plugins.slots.memory = "agentmemory"` reports `unavailable`** — upgrade to v0.9.11+. Older versions of this plugin registered hooks but never called `api.registerMemoryCapability(...)`, so the memory-slot machinery did not consider the slot claimed. The current plugin registers a memory capability (prompt builder) at startup, which is the documented OpenClaw API for occupying the slot. + +**Connection refused on port 3111** — the agentmemory server is not running. Start it with `npx @agentmemory/agentmemory`. + +**No memories returned** — open `http://localhost:3113` and verify observations are being captured. + +## See also + +- [agentmemory main README](../../README.md) +- [Hermes integration](../hermes/README.md) +- [pi integration](../pi/README.md) + +## License + +Apache-2.0 (same as agentmemory) diff --git a/integrations/openclaw/openclaw.plugin.json b/integrations/openclaw/openclaw.plugin.json new file mode 100644 index 0000000..9f15438 --- /dev/null +++ b/integrations/openclaw/openclaw.plugin.json @@ -0,0 +1,27 @@ +{ + "id": "agentmemory", + "kind": "memory", + "name": "agentmemory", + "description": "Persistent cross-session memory for OpenClaw via agentmemory.", + "version": "0.9.4", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "base_url": { "type": "string" }, + "token_budget": { "type": "number" }, + "min_confidence": { "type": "number" }, + "fallback_on_error": { "type": "boolean" }, + "timeout_ms": { "type": "number" } + } + }, + "uiHints": { + "enabled": { "label": "Enabled" }, + "base_url": { "label": "Base URL", "help": "agentmemory REST server base URL" }, + "token_budget": { "label": "Token Budget", "help": "Approximate context budget to inject before the agent starts" }, + "min_confidence": { "label": "Min Confidence" }, + "fallback_on_error": { "label": "Fallback On Error" }, + "timeout_ms": { "label": "Timeout (ms)" } + } +} diff --git a/integrations/openclaw/package.json b/integrations/openclaw/package.json new file mode 100644 index 0000000..c671f5d --- /dev/null +++ b/integrations/openclaw/package.json @@ -0,0 +1,10 @@ +{ + "name": "agentmemory", + "version": "0.9.4", + "type": "module", + "openclaw": { + "extensions": [ + "./plugin.mjs" + ] + } +} diff --git a/integrations/openclaw/plugin.mjs b/integrations/openclaw/plugin.mjs new file mode 100644 index 0000000..332189d --- /dev/null +++ b/integrations/openclaw/plugin.mjs @@ -0,0 +1,217 @@ +/** + * agentmemory plugin for OpenClaw + * + * Deeper integration than raw MCP: + * - claims the plugins.slots.memory slot via api.registerMemoryCapability({ promptBuilder }) + * - recalls relevant memories before the agent starts (before_agent_start hook) + * - captures completed conversation turns after the agent finishes (agent_end hook) + * + * Requires the agentmemory server on localhost:3111. + * Start it with: npx @agentmemory/agentmemory + */ + +const DEFAULT_BASE_URL = "http://localhost:3111"; +const DEFAULT_TIMEOUT_MS = 5000; +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +const configSchema = { + type: "object", + additionalProperties: false, + properties: { + enabled: { type: "boolean" }, + base_url: { type: "string" }, + token_budget: { type: "number" }, + min_confidence: { type: "number" }, + fallback_on_error: { type: "boolean" }, + timeout_ms: { type: "number" }, + }, +}; + +function extractText(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .flatMap((block) => { + if (!block || typeof block !== "object") return []; + if (block.type === "text" && typeof block.text === "string") return [block.text]; + return []; + }) + .join("\n") + .trim(); +} + +function lastAssistantText(messages) { + for (const message of [...messages].reverse()) { + if (!message || typeof message !== "object") continue; + if (message.role !== "assistant") continue; + const text = extractText(message.content); + if (text) return text; + } + return ""; +} + +function latestUserText(messages) { + for (const message of [...messages].reverse()) { + if (!message || typeof message !== "object") continue; + if (message.role !== "user") continue; + const text = extractText(message.content); + if (text) return text; + } + return ""; +} + +function formatResults(results) { + if (!Array.isArray(results) || results.length === 0) return ""; + return results + .slice(0, 5) + .map((result, index) => { + const obs = result?.observation ?? result ?? {}; + const title = (obs.title || `Memory ${index + 1}`).trim(); + const narrative = (obs.narrative || "").trim(); + const type = (obs.type || "memory").trim(); + return `- ${title} (${type})${narrative ? `: ${narrative}` : ""}`; + }) + .join("\n"); +} + +function normalizedHostname(hostname) { + return hostname.replace(/^\[|\]$/g, "").toLowerCase(); +} + +function usesPlaintextBearerAuth(baseUrl, secret) { + if (!secret) return false; + try { + const parsed = new URL(baseUrl); + return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname)); + } catch { + return false; + } +} + +function plaintextBearerAuthMessage(baseUrl) { + return `agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to ${baseUrl}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`; +} + +export function createPlaintextBearerAuthGuard(warn, env) { + let warned = false; + return function guardPlaintextBearerAuth(baseUrl, secret) { + if (!usesPlaintextBearerAuth(baseUrl, secret)) return; + const message = plaintextBearerAuthMessage(baseUrl); + if ((env || process.env).AGENTMEMORY_REQUIRE_HTTPS === "1") throw new Error(message); + if (!warned) { + warned = true; + warn(message); + } + }; +} + +function createClient(cfg, api) { + const baseUrl = String(cfg.base_url || DEFAULT_BASE_URL).replace(/\/+$/, ""); + const timeoutMs = Number(cfg.timeout_ms || DEFAULT_TIMEOUT_MS); + const fallbackOnError = cfg.fallback_on_error !== false; + const secret = process.env.AGENTMEMORY_SECRET; + const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard( + (message) => api.logger.warn?.(message), + ); + if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") { + guardPlaintextBearerAuth(baseUrl, secret); + } + + async function postJson(path, payload) { + guardPlaintextBearerAuth(baseUrl, secret); + const headers = { "Content-Type": "application/json" }; + if (secret) headers.Authorization = `Bearer ${secret}`; + try { + const res = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + if (fallbackOnError) return null; + const body = await res.text().catch(() => ""); + throw new Error(`agentmemory ${path} failed: ${res.status} ${body}`); + } + return await res.json(); + } catch (error) { + if (!fallbackOnError) throw error; + api.logger.warn?.(`agentmemory: ${String(error)}`); + return null; + } + } + + return { postJson, baseUrl }; +} + +const plugin = { + id: "agentmemory", + name: "agentmemory", + description: "Shared cross-session memory via the local agentmemory server.", + configSchema, + register(api) { + const cfg = { + enabled: api.pluginConfig?.enabled !== false, + base_url: api.pluginConfig?.base_url || DEFAULT_BASE_URL, + token_budget: api.pluginConfig?.token_budget || 2000, + min_confidence: api.pluginConfig?.min_confidence || 0.5, + fallback_on_error: api.pluginConfig?.fallback_on_error !== false, + timeout_ms: api.pluginConfig?.timeout_ms || DEFAULT_TIMEOUT_MS, + }; + const client = createClient(cfg, api); + + if (typeof api.registerMemoryCapability === "function") { + api.registerMemoryCapability({ + // OpenClaw passes { availableTools: Set, citationsMode? }. We + // don't currently branch on tool availability, but accept the params + // object so the signature matches MemoryPromptSectionBuilder exactly. + promptBuilder: (_params) => [ + "Long-term memory provider: agentmemory (external REST service on " + + client.baseUrl + + ").", + "agentmemory recalls relevant prior observations before each turn via the before_agent_start hook and captures completed turns via agent_end.", + "Treat recalled context as background, not authoritative — prefer current workspace state and explicit user instructions when they conflict.", + ], + }); + } + + api.on("before_agent_start", async (event) => { + if (!cfg.enabled) return; + const prompt = typeof event?.prompt === "string" ? event.prompt.trim() : ""; + if (!prompt) return; + const result = await client.postJson("/agentmemory/smart-search", { + query: prompt, + limit: 5, + }); + const block = formatResults(result?.results || []); + if (!block) return; + return { + prependContext: `Relevant long-term memory from agentmemory:\n${block}`, + }; + }); + + api.on("agent_end", async (event) => { + if (!cfg.enabled || !event?.success || !Array.isArray(event.messages)) return; + const userText = latestUserText(event.messages); + const assistantText = lastAssistantText(event.messages); + if (!userText || !assistantText) return; + const sessionId = + event.sessionId || + event.sessionKey || + event.runId || + `openclaw-${Date.now()}`; + await client.postJson("/agentmemory/observe", { + hookType: "post_tool_use", + sessionId, + timestamp: new Date().toISOString(), + data: { + tool_name: "conversation", + tool_input: userText.slice(0, 1000), + tool_output: assistantText.slice(0, 4000), + }, + }); + }); + }, +}; + +export default plugin; diff --git a/integrations/openclaw/plugin.yaml b/integrations/openclaw/plugin.yaml new file mode 100644 index 0000000..f991323 --- /dev/null +++ b/integrations/openclaw/plugin.yaml @@ -0,0 +1,27 @@ +name: agentmemory +version: 0.8.1 +description: "Persistent cross-session memory for OpenClaw via agentmemory. 95.2% retrieval accuracy on LongMemEval-S." +author: "Rohit Ghumare" +homepage: "https://github.com/rohitg00/agentmemory" +license: Apache-2.0 + +category: memory +tags: + - memory + - persistence + - mcp + - context + +hooks: + - on_session_start + - on_pre_llm_call + - on_post_tool_use + - on_session_end + +config: + enabled: true + base_url: http://localhost:3111 + token_budget: 2000 + min_confidence: 0.5 + fallback_on_error: true + timeout_ms: 5000 diff --git a/integrations/pi/README.md b/integrations/pi/README.md new file mode 100644 index 0000000..85a2758 --- /dev/null +++ b/integrations/pi/README.md @@ -0,0 +1,77 @@ +

+ agentmemory +

+ +

+  agentmemory for pi +

+ +

+ Your pi sessions remember everything. No more re-explaining.
+ Persistent cross-session memory via agentmemory — shared with Claude Code, Codex CLI, Gemini CLI, Hermes, OpenClaw, and more. +

+ +--- + +## Quick setup + +Start the agentmemory server in a separate terminal: + +```bash +npx @agentmemory/agentmemory +``` + +Copy this folder into pi's global extensions directory: + +```bash +mkdir -p ~/.pi/agent/extensions/agentmemory +cp integrations/pi/index.ts ~/.pi/agent/extensions/agentmemory/index.ts +``` + +Then enable it in `~/.pi/agent/settings.json` if you prefer explicit loading: + +```json +{ + "extensions": ["~/.pi/agent/extensions/agentmemory"] +} +``` + +If you place it under `~/.pi/agent/extensions/agentmemory/`, pi will also auto-discover it and `/reload` can hot-reload it. + +## What it adds + +- `memory_health` — confirm the shared memory server is reachable +- `memory_search` — search prior decisions, bugs, workflows, and preferences +- `memory_save` — write durable facts back to long-term memory +- `/agentmemory-status` — check health from inside pi +- `before_agent_start` recall — injects relevant memories into the prompt +- `agent_end` capture — saves completed conversation turns back to agentmemory + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL | +| `AGENTMEMORY_SECRET` | (none) | Bearer token for protected instances | +| `AGENTMEMORY_REQUIRE_HTTPS` | (off) | When set to `1`, refuse to send a bearer token over plaintext HTTP to a non-loopback host. Sends the token only when `AGENTMEMORY_URL` is `https://...` or points at `localhost`/`127.0.0.1`/`::1`. With this off, the plugin warns once but still sends. | + +## Smoke test + +Run pi and ask it to use the `memory_health` tool, or call the command directly: + +```text +/agentmemory-status +``` + +You should see `agentmemory healthy` and a footer status like `🧠 agentmemory`. + +## Notes + +- This extension uses pi's extension API, not MCP, so it can hook directly into the agent lifecycle. +- One local agentmemory server can be shared across pi, pi2, Hermes, OpenClaw, Claude Code, Codex CLI, and Gemini CLI. + +## See also + +- [agentmemory main README](../../README.md) +- [Hermes integration](../hermes/README.md) +- [OpenClaw integration](../openclaw/README.md) diff --git a/integrations/pi/index.ts b/integrations/pi/index.ts new file mode 100644 index 0000000..9c6cfc7 --- /dev/null +++ b/integrations/pi/index.ts @@ -0,0 +1,275 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import path from "node:path"; +import crypto from "node:crypto"; +import { createPlaintextBearerAuthGuard } from "./security.js"; + +type TextBlock = { type?: string; text?: string }; +type AssistantMessage = { role?: string; content?: unknown }; +type SmartSearchResult = { + title?: string; + narrative?: string; + type?: string; + combinedScore?: number; + score?: number; + observation?: { + title?: string; + narrative?: string; + type?: string; + }; +}; + +type HealthResponse = { + status?: string; + service?: string; + version?: string; + health?: { + status?: string; + notes?: string[]; + }; +}; + +const DEFAULT_URL = process.env.AGENTMEMORY_URL || "http://localhost:3111"; +const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard(); +const TOOL_GUIDANCE = [ + "agentmemory is available for cross-session memory.", + "Use memory_search to recall prior decisions, preferences, bugs, and workflows.", + "Use memory_save when you discover durable facts worth remembering beyond this session.", +].join(" "); + +function normalizeBaseUrl(url: string): string { + return url.replace(/\/+$/, ""); +} + +function getText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .flatMap((part) => { + if (!part || typeof part !== "object") return [] as string[]; + const block = part as TextBlock; + if (block.type === "text" && typeof block.text === "string") return [block.text]; + return [] as string[]; + }) + .join("\n") + .trim(); +} + +function getLastAssistantText(messages: unknown[]): string { + for (const msg of [...messages].reverse()) { + if (!msg || typeof msg !== "object") continue; + const assistant = msg as AssistantMessage; + if (assistant.role !== "assistant") continue; + const text = getText(assistant.content); + if (text) return text; + } + return ""; +} + +function formatSearchResults(results: SmartSearchResult[]): string { + if (!results.length) return "No relevant memories found."; + return results + .slice(0, 5) + .map((result, index) => { + const obs = result.observation ?? result; + const title = obs.title?.trim() || `Memory ${index + 1}`; + const narrative = obs.narrative?.trim() || ""; + const type = obs.type?.trim() || "memory"; + const score = result.combinedScore ?? result.score; + const scoreText = typeof score === "number" ? ` [score=${score.toFixed(3)}]` : ""; + return `- ${title} (${type})${scoreText}${narrative ? `: ${narrative}` : ""}`; + }) + .join("\n"); +} + +async function callAgentMemory( + pathname: string, + options?: { + method?: "GET" | "POST"; + body?: unknown; + baseUrl?: string; + }, +): Promise { + const baseUrl = normalizeBaseUrl(options?.baseUrl || process.env.AGENTMEMORY_URL || DEFAULT_URL); + const method = options?.method || "POST"; + const url = `${baseUrl}/agentmemory/${pathname.replace(/^\/+/, "")}`; + const headers: Record = {}; + const secret = process.env.AGENTMEMORY_SECRET; + guardPlaintextBearerAuth(baseUrl, secret); + if (options?.body !== undefined) headers["Content-Type"] = "application/json"; + if (secret) headers.Authorization = `Bearer ${secret}`; + + try { + const response = await fetch(url, { + method, + headers, + body: options?.body !== undefined ? JSON.stringify(options.body) : undefined, + }); + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + return null; + } +} + +export default function agentmemoryExtension(pi: ExtensionAPI) { + if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") { + guardPlaintextBearerAuth( + normalizeBaseUrl(process.env.AGENTMEMORY_URL || DEFAULT_URL), + process.env.AGENTMEMORY_SECRET, + ); + } + let sessionId = `ephemeral-${crypto.randomUUID().slice(0, 8)}`; + let currentProject = process.cwd(); + let lastPrompt = ""; + let lastHealthOk = false; + + async function getHealth() { + return await callAgentMemory("health", { method: "GET" }); + } + + async function refreshStatus(ctx: { ui: { setStatus: (key: string, text: string) => void } }) { + const health = await getHealth(); + lastHealthOk = !!health && (health.status === "healthy" || health.health?.status === "healthy"); + ctx.ui.setStatus("agentmemory", lastHealthOk ? "🧠 agentmemory" : "🧠 agentmemory off"); + } + + pi.registerCommand("agentmemory-status", { + description: "Check local agentmemory server health", + handler: async (_args, ctx) => { + const health = await getHealth(); + if (!health) { + ctx.ui.notify("agentmemory is unreachable at http://localhost:3111", "warning"); + return; + } + ctx.ui.notify( + `agentmemory ${health.status || health.health?.status || "unknown"}${health.version ? ` v${health.version}` : ""}`, + "info", + ); + }, + }); + + pi.registerTool({ + name: "memory_health", + label: "Memory Health", + description: "Check whether the local agentmemory server is reachable and healthy", + parameters: Type.Object({}), + async execute() { + const health = await getHealth(); + if (!health) { + return { + content: [{ type: "text", text: "agentmemory is unreachable at http://localhost:3111" }], + details: { ok: false }, + }; + } + return { + content: [ + { + type: "text", + text: `agentmemory status: ${health.status || health.health?.status || "unknown"}${health.version ? ` (v${health.version})` : ""}`, + }, + ], + details: health, + }; + }, + }); + + pi.registerTool({ + name: "memory_search", + label: "Memory Search", + description: "Search agentmemory for cross-session project memory, prior decisions, bugs, and user preferences", + parameters: Type.Object({ + query: Type.String({ description: "What to search for in memory" }), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 5, description: "Maximum results" })), + }), + async execute(_toolCallId, params) { + const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", { + body: { query: params.query, limit: params.limit ?? 5 }, + }); + const results = result?.results || []; + return { + content: [{ type: "text", text: formatSearchResults(results) }], + details: { query: params.query, results }, + }; + }, + }); + + pi.registerTool({ + name: "memory_save", + label: "Memory Save", + description: "Save a durable fact, convention, workflow, preference, or bug fix into agentmemory", + parameters: Type.Object({ + content: Type.String({ description: "What should be remembered" }), + type: Type.Optional( + Type.String({ + description: "Memory type", + default: "fact", + }), + ), + }), + async execute(_toolCallId, params) { + const result = await callAgentMemory>("remember", { + body: { content: params.content, type: params.type || "fact" }, + }); + if (!result) { + return { + content: [{ type: "text", text: "Failed to save memory to agentmemory." }], + details: { ok: false }, + }; + } + return { + content: [{ type: "text", text: `Saved memory (${params.type || "fact"}): ${params.content}` }], + details: result, + }; + }, + }); + + pi.on("session_start", async (_event, ctx) => { + const sessionFile = ctx.sessionManager.getSessionFile(); + sessionId = sessionFile ? path.basename(sessionFile).replace(/\.[^.]+$/, "") : `ephemeral-${crypto.randomUUID().slice(0, 8)}`; + currentProject = process.cwd(); + await refreshStatus(ctx); + }); + + pi.on("before_agent_start", async (event, ctx) => { + currentProject = event.systemPromptOptions.cwd || process.cwd(); + lastPrompt = event.prompt?.trim() || ""; + if (!lastPrompt) return; + + const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", { + body: { query: lastPrompt, limit: 5 }, + }); + const results = result?.results || []; + const recallBlock = results.length + ? [ + "Relevant long-term memory from agentmemory:", + formatSearchResults(results), + ].join("\n") + : ""; + + await refreshStatus(ctx); + return { + systemPrompt: [event.systemPrompt, TOOL_GUIDANCE, recallBlock].filter(Boolean).join("\n\n"), + }; + }); + + pi.on("agent_end", async (event) => { + if (!lastHealthOk || !lastPrompt) return; + const assistantText = getLastAssistantText(event.messages as unknown[]); + if (!assistantText) return; + void callAgentMemory("observe", { + body: { + hookType: "post_tool_use", + sessionId, + project: currentProject, + cwd: currentProject, + timestamp: new Date().toISOString(), + data: { + tool_name: "conversation", + tool_input: lastPrompt.slice(0, 500), + tool_output: assistantText.slice(0, 4000), + }, + }, + }); + }); +} diff --git a/integrations/pi/package.json b/integrations/pi/package.json new file mode 100644 index 0000000..eec302d --- /dev/null +++ b/integrations/pi/package.json @@ -0,0 +1,5 @@ +{ + "name": "agentmemory-pi-extension", + "private": true, + "type": "module" +} diff --git a/integrations/pi/security.ts b/integrations/pi/security.ts new file mode 100644 index 0000000..4db0e52 --- /dev/null +++ b/integrations/pi/security.ts @@ -0,0 +1,35 @@ +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +function normalizedHostname(hostname: string): string { + return hostname.replace(/^\[|\]$/g, "").toLowerCase(); +} + +export function usesPlaintextBearerAuth(baseUrl: string, secret?: string): boolean { + if (!secret) return false; + try { + const parsed = new URL(baseUrl); + return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname)); + } catch { + return false; + } +} + +export function plaintextBearerAuthMessage(baseUrl: string): string { + return `agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to ${baseUrl}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`; +} + +export function createPlaintextBearerAuthGuard( + warn: (message: string) => void = (message) => console.warn(message), + env?: { AGENTMEMORY_REQUIRE_HTTPS?: string }, +): (baseUrl: string, secret?: string) => void { + let warned = false; + return (baseUrl, secret) => { + if (!usesPlaintextBearerAuth(baseUrl, secret)) return; + const message = plaintextBearerAuthMessage(baseUrl); + if ((env || process.env).AGENTMEMORY_REQUIRE_HTTPS === "1") throw new Error(message); + if (!warned) { + warned = true; + warn(message); + } + }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4e2bc8c --- /dev/null +++ b/package.json @@ -0,0 +1,93 @@ +{ + "name": "@agentmemory/agentmemory", + "version": "0.9.27", + "description": "Persistent memory for AI coding agents, powered by iii-engine's three primitives", + "type": "module", + "main": "dist/index.mjs", + "types": "dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./dist/standalone.mjs": "./dist/standalone.mjs", + "./package.json": "./package.json" + }, + "bin": { + "agentmemory": "dist/cli.mjs" + }, + "scripts": { + "build": "tsdown && (cp iii-config.yaml dist/ 2>/dev/null || true) && (cp iii-config.docker.yaml dist/ 2>/dev/null || true) && (cp docker-compose.yml dist/ 2>/dev/null || true) && (cp .env.example dist/ 2>/dev/null || true) && mkdir -p dist/viewer && cp src/viewer/index.html dist/viewer/ && cp src/viewer/favicon.svg dist/viewer/", + "dev": "tsx src/index.ts", + "start": "node dist/cli.mjs", + "migrate": "node dist/functions/migrate.js", + "test": "vitest run --exclude test/integration.test.ts", + "test:watch": "vitest --exclude test/integration.test.ts", + "test:integration": "vitest run test/integration.test.ts", + "test:all": "vitest run", + "skills:gen": "tsx scripts/skills/generate.ts", + "skills:check": "tsx scripts/skills/generate.ts --check && tsx scripts/skills/check.ts", + "bench:load": "node --import tsx benchmark/load-100k.ts", + "eval:longmemeval": "tsx eval/runner/longmemeval.ts", + "eval:coding-life": "tsx eval/runner/coding-life.ts" + }, + "keywords": [ + "ai", + "agent", + "memory", + "persistent", + "iii-engine", + "claude-code", + "coding-agent", + "context", + "observation" + ], + "files": [ + "dist/", + "plugin/", + "iii-config.yaml", + "iii-config.docker.yaml", + "docker-compose.yml", + ".env.example", + "LICENSE", + "README.md", + "AGENTS.md" + ], + "author": "Rohit Ghumare ", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rohitg00/agentmemory" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.142", + "@anthropic-ai/sdk": "^0.100.1", + "@clack/prompts": "^1.2.0", + "dotenv": "^17.4.2", + "iii-sdk": "0.11.2", + "picocolors": "^1.1.1", + "zod": "^4.0.0" + }, + "optionalDependencies": { + "@node-rs/jieba": "^2.0.1", + "@xenova/transformers": "^2.17.2", + "onnxruntime-node": "^1.14.0", + "onnxruntime-web": "^1.14.0", + "tiny-segmenter": "^0.2.0" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsdown": "^0.21.10", + "tsx": "^4.19.0", + "typescript": "^6.0.3", + "vitest": "^4.1.6" + }, + "overrides": { + "qs": "^6.15.2", + "ws": "^8.21.0", + "protobufjs": "^7.5.8" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/mcp/LICENSE b/packages/mcp/LICENSE new file mode 100644 index 0000000..cb13c72 --- /dev/null +++ b/packages/mcp/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Rohit Ghumare + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..abd57a7 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,50 @@ +# @agentmemory/mcp + +Standalone MCP server for [agentmemory](https://github.com/rohitg00/agentmemory). + +This is a thin shim package that re-exposes the standalone MCP entrypoint from +[`@agentmemory/agentmemory`](https://www.npmjs.com/package/@agentmemory/agentmemory), +so MCP client configs that say `npx @agentmemory/mcp` work out of the box +without installing the full package first. + +## Usage + +```bash +npx -y @agentmemory/mcp +``` + +Or wire it into your MCP client (Claude Desktop, OpenClaw, Cursor, Codex, etc.): + +```json +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"] + } + } +} +``` + +This package depends on `@agentmemory/agentmemory` and forwards to its +`dist/standalone.mjs` entrypoint. If you already have `@agentmemory/agentmemory` +installed, you can call the same entrypoint directly: + +```bash +npx @agentmemory/agentmemory mcp +``` + +Both commands do the same thing. + +## Why does this package exist? + +The original plan in [issue #120](https://github.com/rohitg00/agentmemory/issues/120) +was to publish `agentmemory-mcp` as an unscoped package, but npm's name-similarity +policy blocks that name because of an unrelated package called `agent-memory-mcp`. +Publishing under the `@agentmemory` scope sidesteps the conflict and keeps the +"dedicated standalone package" UX — `npx @agentmemory/mcp` is one character +longer than `npx agentmemory-mcp` and works on the live registry. + +## License + +Apache-2.0 diff --git a/packages/mcp/bin.mjs b/packages/mcp/bin.mjs new file mode 100755 index 0000000..7df452d --- /dev/null +++ b/packages/mcp/bin.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import("@agentmemory/agentmemory/dist/standalone.mjs").catch((err) => { + console.error( + "[@agentmemory/mcp] Failed to load standalone entrypoint from @agentmemory/agentmemory.", + ); + console.error( + "[@agentmemory/mcp] Try installing manually: npm i -g @agentmemory/agentmemory", + ); + console.error(err instanceof Error ? err.stack || err.message : String(err)); + process.exit(1); +}); diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..e918d9c --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,40 @@ +{ + "name": "@agentmemory/mcp", + "version": "0.9.27", + "description": "Standalone MCP server for agentmemory — thin shim that re-exposes @agentmemory/agentmemory's MCP entrypoint", + "type": "module", + "bin": { + "agentmemory-mcp": "./bin.mjs" + }, + "files": [ + "bin.mjs", + "README.md", + "LICENSE" + ], + "keywords": [ + "ai", + "agent", + "memory", + "mcp", + "agentmemory" + ], + "author": "Rohit Ghumare ", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rohitg00/agentmemory", + "directory": "packages/mcp" + }, + "homepage": "https://github.com/rohitg00/agentmemory#readme", + "bugs": "https://github.com/rohitg00/agentmemory/issues", + "dependencies": { + "@agentmemory/agentmemory": "~0.9.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..cb4e6df --- /dev/null +++ b/plugin/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "agentmemory", + "version": "0.9.27", + "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 53 MCP tools, 8 skills, real-time viewer.", + "author": { + "name": "Rohit Ghumare", + "url": "https://github.com/rohitg00" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/rohitg00/agentmemory", + "repository": "https://github.com/rohitg00/agentmemory", + "skills": ["./skills/"] +} diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json new file mode 100644 index 0000000..7c42f13 --- /dev/null +++ b/plugin/.codex-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "agentmemory", + "version": "0.9.27", + "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 6 hooks, 53 MCP tools, 8 skills, real-time viewer.", + "author": { + "name": "Rohit Ghumare", + "url": "https://github.com/rohitg00" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/rohitg00/agentmemory", + "repository": "https://github.com/rohitg00/agentmemory", + "skills": "./skills/", + "mcpServers": "./.mcp.json", + "hooks": "./hooks/hooks.codex.json" +} diff --git a/plugin/.mcp.copilot.json b/plugin/.mcp.copilot.json new file mode 100644 index 0000000..827330a --- /dev/null +++ b/plugin/.mcp.copilot.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "agentmemory": { + "type": "local", + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL:-http://localhost:3111}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET:-}", + "AGENTMEMORY_TOOLS": "${AGENTMEMORY_TOOLS:-all}" + }, + "tools": ["*"] + } + } +} diff --git a/plugin/.mcp.json b/plugin/.mcp.json new file mode 100644 index 0000000..37f7754 --- /dev/null +++ b/plugin/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "agentmemory": { + "command": "npx", + "args": ["-y", "@agentmemory/mcp"], + "env": { + "AGENTMEMORY_URL": "${AGENTMEMORY_URL:-http://localhost:3111}", + "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET:-}", + "AGENTMEMORY_TOOLS": "${AGENTMEMORY_TOOLS:-all}" + } + } + } +} diff --git a/plugin/hooks/hooks.codex.json b/plugin/hooks/hooks.codex.json new file mode 100644 index 0000000..d2c3a3b --- /dev/null +++ b/plugin/hooks/hooks.codex.json @@ -0,0 +1,67 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\"", + "statusMessage": "agentmemory: loading session context" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"", + "statusMessage": "agentmemory: recalling relevant memories" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|Read|Glob|Grep", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.mjs\"" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-compact.mjs\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.mjs\"" + } + ] + } + ] + } +} diff --git a/plugin/hooks/hooks.copilot.json b/plugin/hooks/hooks.copilot.json new file mode 100644 index 0000000..b7d09f8 --- /dev/null +++ b/plugin/hooks/hooks.copilot.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/session-start.mjs" + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/prompt-submit.mjs" + } + ], + "preToolUse": [ + { + "type": "command", + "matcher": "edit|write|create|read|view|glob|grep", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/pre-tool-use.mjs" + } + ], + "postToolUse": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-use.mjs" + } + ], + "postToolUseFailure": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-failure.mjs" + } + ], + "preCompact": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/pre-compact.mjs" + } + ], + "agentStop": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/stop.mjs" + } + ], + "sessionEnd": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/session-end.mjs" + } + ], + "subagentStart": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/subagent-start.mjs" + } + ], + "subagentStop": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/subagent-stop.mjs" + } + ], + "notification": [ + { + "type": "command", + "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/notification.mjs" + } + ] + } +} diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json new file mode 100644 index 0000000..a13c997 --- /dev/null +++ b/plugin/hooks/hooks.json @@ -0,0 +1,125 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\"" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|Read|Glob|Grep", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\"" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.mjs\"" + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-failure.mjs\"" + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-compact.mjs\"" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-start.mjs\"" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-stop.mjs\"" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/notification.mjs\"" + } + ] + } + ], + "TaskCompleted": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/task-completed.mjs\"" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.mjs\"" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-end.mjs\"" + } + ] + } + ] + } +} diff --git a/plugin/opencode/README.md b/plugin/opencode/README.md new file mode 100644 index 0000000..60f5e5b --- /dev/null +++ b/plugin/opencode/README.md @@ -0,0 +1,229 @@ +

+ OpenCode +  agentmemory for OpenCode +

+ +

+ Your OpenCode agents remember everything. No more re-explaining.
+ Persistent cross-session memory via agentmemory — 95.2% retrieval accuracy on LongMemEval-S. +

+ +

+ 53 MCP tools + 22 hooks + 2 slash commands + 95.2% R@5 +

+ +--- + +## Quick start + +### 1. Start the agentmemory server + +```bash +npx @agentmemory/agentmemory +``` + +The server starts on `http://localhost:3111`. + +### 2. Configure the MCP server + +Add to `~/.config/opencode/opencode.json` or your project's `.opencode/opencode.json`: + +```json +{ + "mcp": { + "agentmemory": { + "type": "local", + "command": ["npx", "-y", "@agentmemory/mcp"], + "enabled": true + } + } +} +``` + +### 3. Install the plugin + +Add to `~/.config/opencode/opencode.json`: + +```json +{ + "plugin": ["./plugins/agentmemory-capture.ts"] +} +``` + +Copy the plugin file from this repo: + +```bash +mkdir -p ~/.config/opencode/plugins +cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/ +``` + +### 4. Add the slash commands + +Copy the commands into your project or global `.opencode/commands/` directory: + +```bash +mkdir -p ~/.config/opencode/commands +cp plugin/opencode/commands/recall.md ~/.config/opencode/commands/ +cp plugin/opencode/commands/remember.md ~/.config/opencode/commands/ +``` + +Restart OpenCode or open a new session. The plugin auto-captures everything. + +## What gets captured + +### Session lifecycle + +| Event | Hook | agentmemory API | +|---|---|---| +| Session start | `session.created` | POST /session/start | +| Idle → summarize | `session.idle` + `session.status` (idle) | POST /summarize | +| Status transitions | `session.status` (idle/busy/retry) | POST /observe | +| Compaction | `session.compacted` | POST /summarize + POST /observe | +| Metadata updates | `session.updated` | POST /observe | +| Code change tracking | `session.diff` | POST /observe | +| Session delete | `session.deleted` | POST /session/end | +| Session error | `session.error` | POST /observe | + +### Messages & prompts + +| Event | Hook | agentmemory API | +|---|---|---| +| User prompt (rich) | `chat.message` | POST /observe | +| User prompt metadata | `message.updated` (user) | POST /observe | +| Assistant response | `message.updated` (assistant) | POST /observe | +| Message removed (undo) | `message.removed` | POST /observe | + +### Parts & steps + +| Event | Hook | agentmemory API | +|---|---|---| +| Subagent start | `message.part.updated` (subtask) | POST /observe | +| Tool completed | `message.part.updated` (tool completed) | POST /observe | +| Tool error | `message.part.updated` (tool error) | POST /observe | +| Step finish (cost/tokens) | `message.part.updated` (step-finish) | POST /observe | +| Reasoning trace | `message.part.updated` (reasoning) | POST /observe | +| Patch applied | `message.part.updated` (patch) | POST /observe | +| Auto/manual compaction | `message.part.updated` (compaction) | POST /observe | +| Agent selection | `message.part.updated` (agent) | POST /observe | +| API retry | `message.part.updated` (retry) | POST /observe | + +### File enrichment pipeline + +| Event | Hook | agentmemory API | +|---|---|---| +| File tool params | `tool.execute.before` → stash paths | — | +| File edited | `file.edited` → stash paths | — | +| File part attached | `message.part.updated` (file) → stash paths | — | +| Enrichment inject | `experimental.chat.system.transform` | POST /enrich → `output.system[]` | +| Memory context inject | `experimental.chat.system.transform` | POST /context → `output.system[]` | + +### Permissions + +| Event | Hook | agentmemory API | +|---|---|---| +| Permission prompt | `permission.updated` | POST /observe | +| Permission reply | `permission.replied` | POST /observe | + +### Tasks & commands + +| Event | Hook | agentmemory API | +|---|---|---| +| Task tracking (w/ priority) | `todo.updated` | POST /observe | +| Command executed | `command.executed` | POST /observe | + +### Model & config + +| Event | Hook | agentmemory API | +|---|---|---| +| LLM parameters | `chat.params` | POST /observe | +| Config loaded | `config` | POST /observe | +| Compaction (WIP) | `experimental.session.compacting` | POST /context → `output.context[]` | + +### File enrichment + memory injection (two-layer pipeline) + +`experimental.chat.system.transform` fires before every LLM call and injects two layers of context: + +1. **Memory context** (once per session): calls `/agentmemory/context` and injects project profile, recent session summaries, and important past observations into the system prompt. This is the OpenCode equivalent of Claude's MEMORY.md bridge — instead of syncing to a markdown file, context is injected directly into the system prompt. + +2. **File enrichment** (every turn with stashed files): calls `/agentmemory/enrich` with files stashed by `tool.execute.before`, `file.edited`, and `message.part.updated` (file parts). File-specific context (past observations, related bugs, semantic search) is injected into the system prompt. + +```text +System prompt = [OpenCode instructions] + [memory context] + [file enrichment] + [user message] + ^ ^ + first turn only every file-touching turn +``` + +**Differences from Claude's PreToolUse:** + +| Dimension | Claude (PreToolUse) | OpenCode (two-hop pipeline) | +|---|---|---| +| Injection mechanism | stdout → context window | `output.system[]` → system prompt | +| Timing | Same turn (parallel with tool) | Next turn (before next LLM call) | +| File set | Per-tool (immediate) | Batched (all files since last enrichment) | +| Coverage | Edit/Write/Read/Glob/Grep only | Edit/Write/Read/Glob/Grep only | +| What gets injected | `` + bug memories | Identical `/enrich` response | + +## MEMORY.md vs AGENTS.md: how context flows + +Claude Code and OpenCode take fundamentally different approaches to injecting memory context into the agent's system prompt. + +### Claude Code: file-backed bridge (two-hop) + +``` +agentmemory ──write──▶ MEMORY.md ──read──▶ Claude system prompt +``` + +- The `claude-bridge/sync` endpoint serializes agentmemory observations into a `MEMORY.md` file in the project root +- Claude Code reads `MEMORY.md` on session start and prepends it to the system prompt +- **Sync is periodic** — sessions only get fresh context when the bridge last ran (session end, pre-compact) +- **Coupling**: memory data lives in a git-trackable file, visible to CI, team members, and other tools + +### OpenCode: direct injection (one-hop) + +``` +agentmemory ──push──▶ OpenCode system prompt +``` + +- `experimental.chat.system.transform` calls `/context` at runtime and pushes the response directly into `output.system[]` +- **Always current** — context is fetched at session start (once) and before file-touching turns (per-batch) +- **No file intermediary** — no stale copies, no merge conflicts, no disk I/O +- `AGENTS.md` is a static instruction file for project conventions, coding standards, and tool guidance — agentmemory does not read or write it + +### Tradeoffs + +| Dimension | Claude (MEMORY.md bridge) | OpenCode (direct injection) | +|---|---|---| +| Freshness | Stale between syncs | Always current (fetched at call time) | +| Visibility | Human-readable file in repo | In-memory injection only | +| Simplicity | Two moving parts (bridge + file) | One step (API → system prompt) | +| Team sharing | File is git-trackable, CI-friendly | Memory shared via agentmemory server API | +| Integration | Any tool can read MEMORY.md | Requires OpenCode plugin SDK | + +### Why OpenCode goes direct + +agentmemory already persists everything in SQLite (`data/state_store.db`). Adding an intermediate MEMORY.md file would duplicate data, introduce sync lag, and require the model to re-parse structured context from markdown. Direct injection delivers the same data with lower latency and zero staleness — the agent always sees what agentmemory knows right now. + +## Slash commands + +- `/recall ` — Search past observations and lessons +- `/remember ` — Save an insight to long-term memory + +## Session instruction injection + +Agentmemory usage instructions are injected into the system prompt on the first turn of every session via `experimental.chat.system.transform` (alongside memory context from `/context`). This is functionally equivalent to Claude Code's skills mechanism — the agent learns which `agentmemory_memory_*` tools to use and when, without needing separate skill invocations. + +## What's not covered (vs Claude Code plugin) + +| Claude feature | Reason | +|---|---| +| SubagentStop | OpenCode's `SubtaskPart` type has no completion/result fields; subtask lifecycle ends are not exposed as distinct events in the OpenCode SDK | +| TaskCompleted | No team/teammate concept in OpenCode; `todo.updated` captures task state changes as a partial equivalent | +| Stop | `session.compacted` event handler exists; `experimental.session.compacting` injection hook defined in SDK but Go binary (v1.14.41) doesn't wire it — will auto-activate when upstream implements it | +| Skills (remember/recall/forget/session-history) | Covered by injected system instructions via `experimental.chat.system.transform` — agent receives usage guidance on first turn | +| Consolidation pipeline (crystals/auto + consolidate-pipeline) | Now called on `session.deleted` — mirrors Claude's `CONSOLIDATION_ENABLED=true` behavior | +| Claude MEMORY.md bridge | OpenCode-specific; OpenCode uses its own AGENTS.md mechanism, not Claude's MEMORY.md | + +All other Claude Code hooks have direct or pipeline equivalents in this plugin. 12 of 12 Claude hook types covered. diff --git a/plugin/opencode/agentmemory-capture.ts b/plugin/opencode/agentmemory-capture.ts new file mode 100644 index 0000000..056d53d --- /dev/null +++ b/plugin/opencode/agentmemory-capture.ts @@ -0,0 +1,687 @@ +import type { Plugin } from "@opencode-ai/plugin"; + +const API = process.env.AGENTMEMORY_URL || "http://localhost:3111"; +const FILE_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]); +const FILE_KEYS = ["filePath", "file_path", "path", "file", "pattern"]; +const MAX_STASHED_FILES = 20; + +const DEBUG = process.env.OPENCODE_AGENTMEMORY_DEBUG === "1"; +const SECRET = process.env.AGENTMEMORY_SECRET || ""; + +function authHeaders(): Record { + const headers: Record = { "Content-Type": "application/json" }; + if (SECRET) headers["Authorization"] = `Bearer ${SECRET}`; + return headers; +} + +async function post(path: string, body: Record, timeoutMs = 5000): Promise { + try { + await fetch(`${API}/agentmemory${path}`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (e) { + if (DEBUG) console.error(`[agentmemory] POST ${path} failed:`, (e as Error).message); + } +} + +async function postJson(path: string, body: Record): Promise { + try { + const res = await fetch(`${API}/agentmemory${path}`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), + }); + return res.ok ? await res.json() : null; + } catch (e) { + if (DEBUG) console.error(`[agentmemory] POST ${path} failed:`, (e as Error).message); + return null; + } +} + +async function observe( + sessionId: string, + hookType: string, + data: Record, +): Promise { + await post("/observe", { + hookType, + sessionId, + project: projectPath, + cwd: projectPath, + timestamp: new Date().toISOString(), + data, + }); +} + +let activeSessionId: string | null = null; +let pendingConfig: Record | null = null; +let projectPath: string | null = null; +const stashedFiles = new Map>(); +const seenSubtaskIds = new Map>(); +const seenToolCallIds = new Map>(); +const contextInjectedSessions = new Set(); +// cache the context returned by POST /session/start so the chat +// system-transform hook can inject it without a second /context fetch. +// Auto-injection now happens at session.created (immediately) AND at +// the first prompt_submit (fallback for older OpenCode builds that +// don't implement experimental.chat.system.transform). +const startContextCache = new Map(); + +function stashFor(sid: string): Set { + let s = stashedFiles.get(sid); + if (!s) { s = new Set(); stashedFiles.set(sid, s); } + return s; +} + +function subtaskSetFor(sid: string): Set { + let s = seenSubtaskIds.get(sid); + if (!s) { s = new Set(); seenSubtaskIds.set(sid, s); } + return s; +} + +function toolCallSetFor(sid: string): Set { + let s = seenToolCallIds.get(sid); + if (!s) { s = new Set(); seenToolCallIds.set(sid, s); } + return s; +} + +function pruneSessionMaps(sid: string): void { + stashedFiles.delete(sid); + seenSubtaskIds.delete(sid); + seenToolCallIds.delete(sid); +} + +function safeSlice(v: unknown, max: number): string { + if (typeof v === "string") return v.slice(0, max); + if (v == null) return ""; + try { return JSON.stringify(v).slice(0, max); } catch { return ""; } +} + +const AGENTMEMORY_INSTRUCTIONS = ` +You have access to agentmemory for persistent cross-session memory. Use these tools proactively. + +CORE TOOLS: + +memory_save — Save an insight, decision, or fact to long-term memory. + Required: content (text), concepts (2-5 comma-separated keywords), type (pattern/preference/architecture/bug/workflow/fact) + Optional: files (comma-separated paths) + Use when: user says "remember this", after discovering a bug, after making an architectural decision, after learning a project convention. + +memory_recall — Search past observations by keywords. + Use when: user says "recall", "what did we do", "do you remember", or needs context from past sessions. + +memory_smart_search — Hybrid semantic+keyword search with progressive disclosure. + Use when: you need the most relevant past context, fuzzy/conceptual searches, or recall doesn't find what you need. + +memory_sessions — List recent sessions with status and observation counts. + Use when: user asks about session/past history, "what did we work on". + +memory_file_history — Get past observations about specific files (across all sessions). + Use when: you're about to edit a file and want to know its history, common pitfalls, or past edits. + +memory_lesson_save — Save a lesson learned (what worked, what to avoid). + Use when: you discover a pattern that could help future sessions avoid mistakes. + +memory_lesson_recall — Search lessons by query. Returns lessons sorted by confidence. + Use when: before making a decision, check if past lessons apply. + +memory_governance_delete — Delete specific memories. Requires explicit user confirmation. + Use when: user says "forget this", "delete that memory". + +memory_patterns — Detect recurring patterns across sessions. + Use when: you want to understand project-level trends over time. + +memory_consolidate — Run the 4-tier memory consolidation pipeline. + Use when: you want to compress and organize accumulated session observations. + +All memory tools start with \`agentmemory_memory_\`. Use the exact names as they appear in your tool list. Tool results are JSON. Always check what was returned before presenting to the user. +`; + +function extractFilePaths(args: Record): string[] { + const files: string[] = []; + for (const key of FILE_KEYS) { + const val = args[key]; + if (typeof val === "string" && val.length > 0) { + files.push(val); + } + } + return files; +} + +function extractErrorMessage(err: unknown): string { + if (typeof err === "string") return err; + if (err && typeof err === "object") { + const e = err as Record; + if (typeof e.message === "string") return e.message; + if (e.data && typeof e.data === "object") { + const d = e.data as Record; + if (typeof d.message === "string") return d.message; + } + if (typeof e.name === "string") return e.name; + try { return JSON.stringify(err); } catch { return ""; } + } + return String(err ?? ""); +} + +export const AgentmemoryCapturePlugin: Plugin = async (ctx) => { + projectPath = ctx.worktree || ctx.project?.id || process.cwd(); + + return { + event: async ({ event }) => { + const type = event.type; + const props = (event as any).properties || {}; + + // ── session.created ── + if (type === "session.created") { + const info = props.info as Record | undefined; + activeSessionId = (info?.id as string) || props.sessionID || null; + if (!activeSessionId) return; + stashedFiles.set(activeSessionId, new Set()); + seenSubtaskIds.delete(activeSessionId); + seenToolCallIds.delete(activeSessionId); + contextInjectedSessions.delete(activeSessionId); + // Snapshot the session id locally — `activeSessionId` is mutable + // and another `session.created` event during the await could + // rebind it, causing context to be cached against the wrong key. + const sessionId = activeSessionId; + const startResult = await postJson("/session/start", { + sessionId, + title: info?.title ?? null, + parentID: info?.parentID ?? null, + version: info?.version ?? null, + project: projectPath, + cwd: projectPath, + }); + // cache the context returned at session/start so the + // chat.system.transform hook injects it without a second fetch. + const startCtx = (startResult as any)?.context; + if (typeof startCtx === "string" && startCtx.length > 0) { + startContextCache.set(sessionId, startCtx); + } + if (pendingConfig) { + await observe(sessionId, "config_loaded", pendingConfig); + pendingConfig = null; + } + } + + // ── session.idle ── (summarize handled in session.status idle branch) + + // ── session.status ── + if (type === "session.status") { + const status = props.status as Record | undefined; + const sid = props.sessionID || activeSessionId; + if (!sid || !status) return; + if (status.type === "idle") { + await post("/summarize", { sessionId: sid }); + } + await observe(sid, "session_status", { + status_type: status.type, + attempt: status.attempt ?? null, + message: safeSlice(status.message, 2000), + }); + } + + // ── session.compacted ── + if (type === "session.compacted") { + const sid = props.sessionID || activeSessionId; + if (sid) { + await post("/summarize", { sessionId: sid }); + await observe(sid, "session_compacted", {}); + } + } + + // ── session.updated ── + if (type === "session.updated") { + const info = props.info as Record | undefined; + const sid = (info?.id as string) || props.sessionID || activeSessionId; + if (!sid) return; + await observe(sid, "session_updated", { + title: info?.title ?? null, + parentID: info?.parentID ?? null, + additions: (info?.summary as any)?.additions ?? null, + deletions: (info?.summary as any)?.deletions ?? null, + files: (info?.summary as any)?.files ?? null, + }); + } + + // ── session.diff ── + if (type === "session.diff") { + const sid = props.sessionID || activeSessionId; + if (!sid || !Array.isArray(props.diff)) return; + const diffs = props.diff as Array>; + await observe(sid, "session_diff", { + files: diffs.map(d => d.file), + additions: diffs.reduce((s, d) => s + ((d.additions as number) || 0), 0), + deletions: diffs.reduce((s, d) => s + ((d.deletions as number) || 0), 0), + diffs: diffs.slice(0, 50), + }); + } + + // ── session.deleted ── + if (type === "session.deleted") { + const sid = props.info?.id || props.sessionID || activeSessionId; + if (!sid) { + if (DEBUG) console.error("[agentmemory] session.deleted with no session ID"); + return; + } + await post("/session/end", { sessionId: sid }); + post("/crystals/auto", { olderThanDays: 7 }, 30000); + post("/consolidate-pipeline", { tier: "all", force: true }, 30000); + if (sid === activeSessionId) activeSessionId = null; + stashedFiles.delete(sid); + startContextCache.delete(sid); + seenSubtaskIds.delete(sid); + seenToolCallIds.delete(sid); + contextInjectedSessions.delete(sid); + } + + // ── session.error ── + if (type === "session.error") { + const sid = props.sessionID || activeSessionId; + if (sid) { + await observe(sid, "post_tool_failure", { + tool_name: "session.error", + tool_input: "", + tool_output: safeSlice(props.error, 8000), + }); + } + } + + // ── message.updated ── + if (type === "message.updated") { + const info = props.info as Record | undefined; + if (!info) return; + + if (info.role === "assistant") { + const sid = props.sessionID || (info.sessionID as string) || activeSessionId; + if (!sid) return; + const tokens = info.tokens as Record | undefined; + const error = info.error ? extractErrorMessage(info.error) : null; + await observe(sid, "assistant_message", { + messageID: info.id, + parentID: info.parentID, + modelID: info.modelID, + providerID: info.providerID, + mode: info.mode, + cost: info.cost ?? 0, + tokens: { + input: tokens?.input ?? 0, + output: tokens?.output ?? 0, + reasoning: tokens?.reasoning ?? 0, + cache_read: (tokens?.cache as any)?.read ?? 0, + cache_write: (tokens?.cache as any)?.write ?? 0, + }, + finish: info.finish ?? null, + error, + duration_ms: (info.time && typeof (info.time as any).completed === "number") + ? (info.time as any).completed - ((info.time as any).created || 0) + : null, + }); + } + } + + // ── message.removed ── + if (type === "message.removed") { + const sid = props.sessionID || activeSessionId; + if (sid) { + await observe(sid, "message_removed", { + messageID: props.messageID, + }); + } + } + + // ── message.part.updated ── + if (type === "message.part.updated") { + const part = props.part as Record | undefined; + if (!part) return; + const sid = (part.sessionID as string) || props.sessionID || activeSessionId; + if (!sid) return; + + if (part.type === "subtask") { + const subtaskId = part.id as string; + if (!subtaskId) return; + const subtaskSet = subtaskSetFor(sid); + if (subtaskSet.has(subtaskId)) return; + subtaskSet.add(subtaskId); + await observe(sid, "subagent_start", { + subtask_id: part.id, + agent: part.agent, + prompt: safeSlice(part.prompt, 4000), + description: safeSlice(part.description, 2000), + }); + return; + } + + if (part.type === "tool") { + const state = part.state as Record | undefined; + if (!state) return; + const callId = part.callID as string; + if (!callId) return; + const toolName = part.tool as string; + + if (state.status === "completed") { + const callSet = toolCallSetFor(sid); + if (callSet.has(callId)) return; + callSet.add(callId); + const st = state as Record; + const rawTime = (st.time as any) || {}; + const startTime = typeof rawTime.start === "number" ? rawTime.start : null; + const endTime = typeof rawTime.end === "number" ? rawTime.end : null; + await observe(sid, "post_tool_use", { + tool_name: toolName, + call_id: callId, + tool_input: safeSlice(st.input, 4000), + tool_output: safeSlice(st.output, 8000), + title: st.title ?? null, + metadata: st.metadata || {}, + duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null, + attachments: Array.isArray(st.attachments) + ? (st.attachments as Array>).map(a => a.filename || a.url) + : [], + }); + } else if (state.status === "error") { + const callSet = toolCallSetFor(sid); + if (callSet.has(callId)) return; + callSet.add(callId); + const st = state as Record; + const rawTime = (st.time as any) || {}; + const startTime = typeof rawTime.start === "number" ? rawTime.start : null; + const endTime = typeof rawTime.end === "number" ? rawTime.end : null; + await observe(sid, "post_tool_failure", { + tool_name: toolName, + call_id: callId, + tool_input: safeSlice(st.input, 4000), + tool_output: safeSlice(st.error, 8000), + duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null, + }); + } + return; + } + + if (part.type === "step-finish") { + await observe(sid, "step_finish", { + messageID: part.messageID, + reason: part.reason ?? null, + cost: (part as any).cost ?? 0, + input_tokens: ((part as any).tokens?.input as number) ?? 0, + output_tokens: ((part as any).tokens?.output as number) ?? 0, + reasoning_tokens: ((part as any).tokens?.reasoning as number) ?? 0, + }); + return; + } + + if (part.type === "reasoning") { + await observe(sid, "reasoning", { + messageID: part.messageID, + text: safeSlice((part as any).text, 4000), + }); + return; + } + + if (part.type === "file") { + const filename = (part as any).filename || (part as any).url || null; + if (filename) stashFor(sid).add(filename); + return; + } + + if (part.type === "patch") { + await observe(sid, "patch_applied", { + messageID: part.messageID, + hash: (part as any).hash, + files: (part as any).files || [], + }); + return; + } + + if (part.type === "compaction") { + await observe(sid, "compaction_event", { + messageID: part.messageID, + auto: (part as any).auto ?? false, + }); + return; + } + + if (part.type === "agent") { + await observe(sid, "agent_selected", { + messageID: part.messageID, + name: (part as any).name, + }); + return; + } + + if (part.type === "retry") { + await observe(sid, "retry_attempt", { + messageID: part.messageID, + attempt: (part as any).attempt, + error: safeSlice((part as any).error, 2000), + }); + return; + } + } + + // ── file.edited ── + if (type === "file.edited") { + const sid = props.sessionID || activeSessionId; + if (sid && typeof props.file === "string" && props.file.length > 0) { + const stash = stashFor(sid); + stash.add(props.file); + if (stash.size > MAX_STASHED_FILES) { + const keep = [...stash].slice(-MAX_STASHED_FILES); + stash.clear(); + for (const f of keep) stash.add(f); + } + } + } + + // ── permission.updated ── + if (type === "permission.updated") { + const sid = props.sessionID || activeSessionId; + if (!sid) return; + await observe(sid, "notification", { + notification_type: "permission_prompt", + permission: props.type || "unknown", + pattern: Array.isArray(props.pattern) + ? props.pattern.join(", ") + : (props.pattern || ""), + tool_call_id: props.callID || null, + title: props.title || props.type || "", + metadata: props.metadata || {}, + }); + } + + // ── permission.replied ── + if (type === "permission.replied") { + const sid = props.sessionID || activeSessionId; + if (!sid) return; + await observe(sid, "permission_replied", { + permission_id: props.permissionID || props.requestID || "", + response: props.response || props.reply || "", + }); + } + + // ── todo.updated ── + if (type === "todo.updated") { + const sid = props.sessionID || activeSessionId; + const todos = Array.isArray(props.todos) ? props.todos.slice(0, 100) : []; + if (!sid || todos.length === 0) return; + const completed = todos.filter((t: any) => t.status === "completed"); + const active = todos.filter((t: any) => t.status !== "completed"); + await observe(sid, "task_completed", { + completed: completed.map((t: any) => ({ content: t.content, priority: t.priority })), + in_progress: active.map((t: any) => ({ content: t.content, priority: t.priority })), + total: todos.length, + }); + } + + // ── command.executed ── + if (type === "command.executed") { + const sid = props.sessionID || activeSessionId; + if (sid) { + await observe(sid, "command_executed", { + name: props.name, + arguments: props.arguments || "", + }); + } + } + }, + + // ── chat.message ── + "chat.message": async (input, output) => { + const sid = input.sessionID || activeSessionId; + if (!sid) return; + const parts = output.parts || []; + const files = parts + .filter((p: any) => p.type === "file") + .map((p: any) => p.filename || p.url) + .filter(Boolean); + for (const f of files) { + const stash = stashFor(sid); + stash.add(f); + if (stash.size > MAX_STASHED_FILES) { + const keep = [...stash].slice(-MAX_STASHED_FILES); + stash.clear(); + for (const k of keep) stash.add(k); + } + } + + const textParts = parts.filter((p: any) => p.type === "text" && !p.synthetic && !p.ignored); + const userText = textParts.map((p: any) => p.text || "").join("\n"); + + await observe(sid, "prompt_submit", { + agent: input.agent ?? null, + model: input.model ?? null, + variant: input.variant ?? null, + prompt: userText.slice(0, 8000), + files: files.slice(0, 20), + parts_summary: parts.map((p: any) => p.type).filter(Boolean), + }); + }, + + // ── chat.params ── + "chat.params": async (input, output) => { + if (!input.model || !output) return; + const sid = input.sessionID || activeSessionId; + if (!sid) return; + await observe(sid, "llm_params", { + agent: input.agent, + model: `${input.model.providerID}/${input.model.id}`, + provider_url: input.model.api?.url ?? null, + temperature: output.temperature, + topP: output.topP, + max_output_tokens: input.model.limit?.output ?? null, + context_limit: input.model.limit?.context ?? null, + cost_1k_input: input.model.cost?.input ?? 0, + cost_1k_output: input.model.cost?.output ?? 0, + }); + }, + + // ── tool.execute.before ── + "tool.execute.before": async (input, output) => { + if (!FILE_TOOLS.has(input.tool)) return; + const sid = input.sessionID || activeSessionId; + if (!sid) return; + const args = output.args as Record | undefined; + if (!args) return; + const stash = stashFor(sid); + for (const fp of extractFilePaths(args)) { + stash.add(fp); + } + if (stash.size > MAX_STASHED_FILES) { + const keep = [...stash].slice(-MAX_STASHED_FILES); + stash.clear(); + for (const f of keep) stash.add(f); + } + }, + + // ── experimental.chat.system.transform ── + "experimental.chat.system.transform": async (input, output) => { + const sid = input.sessionID || activeSessionId; + if (!sid) return; + + if (!contextInjectedSessions.has(sid)) { + if (!Array.isArray(output.system)) return; + output.system.push(AGENTMEMORY_INSTRUCTIONS); + // prefer the context already fetched at session.created; + // fall back to a fresh /context call if the cache missed (e.g. + // session resumed across plugin reloads). + let ctx = startContextCache.get(sid); + if (typeof ctx !== "string" || ctx.length === 0) { + const result = await postJson("/context", { + sessionId: sid, + project: projectPath, + }); + ctx = (result as any)?.context; + } else { + startContextCache.delete(sid); + } + if (typeof ctx === "string" && ctx.length > 0) { + output.system.push(ctx); + } + contextInjectedSessions.add(sid); + } + + const stash = stashFor(sid); + if (stash.size === 0) return; + const files = [...stash].slice(0, 10); + + const enrichResult = await postJson("/enrich", { + sessionId: sid, + files, + toolName: "enrich_inject", + }); + + const enrichCtx = (enrichResult as any)?.context; + if (typeof enrichCtx === "string" && enrichCtx.length > 0) { + if (Array.isArray(output.system)) { + output.system.push(enrichCtx); + } + for (const f of files) stash.delete(f); + } + }, + + // ── experimental.session.compacting (WIP) ── + "experimental.session.compacting": async (input, output) => { + const sid = input.sessionID || activeSessionId; + if (!sid) return; + + const result = await postJson("/context", { + sessionId: sid, + project: projectPath, + }); + const ctx = (result as any)?.context; + if (typeof ctx === "string" && ctx.length > 0) { + if (Array.isArray(output.context)) { + output.context.push(ctx); + } + } + }, + + // ── config ── + config: async (input) => { + const payload: Record = { + theme: input.theme ?? null, + model: input.model ?? null, + autoupdate: input.autoupdate ?? null, + agents: typeof input.agent === "object" && input.agent !== null && !Array.isArray(input.agent) + ? Object.keys(input.agent as Record) + : Array.isArray(input.agent) ? input.agent : [], + mcp_servers: typeof input.mcp === "object" && input.mcp !== null && !Array.isArray(input.mcp) + ? Object.keys(input.mcp as Record) + : Array.isArray(input.mcp) ? input.mcp : [], + providers: typeof input.provider === "object" && input.provider !== null && !Array.isArray(input.provider) + ? Object.keys(input.provider as Record) + : Array.isArray(input.provider) ? input.provider : [], + permission: input.permission ?? null, + }; + if (activeSessionId) { + await observe(activeSessionId, "config_loaded", payload); + } else { + pendingConfig = payload; + } + }, + }; +}; diff --git a/plugin/opencode/commands/recall.md b/plugin/opencode/commands/recall.md new file mode 100644 index 0000000..de01f08 --- /dev/null +++ b/plugin/opencode/commands/recall.md @@ -0,0 +1,19 @@ +Search past session observations and lessons for relevant context. Wrap the `memory_smart_search` and `memory_lesson_recall` MCP tools. + +## Usage + +``` +/recall [query] +``` + +## Instructions + +1. Call `memory_smart_search` with the query and `limit: 10` (hybrid BM25 + vector + graph search). +2. Call `memory_lesson_recall` with the same query and `limit: 5` (lesson search). +3. Combine results and present to the user: + - Group by session + - Show type, title, and narrative for each observation + - Highlight high-importance (>= 7) observations + - Show lessons separately with confidence scores +4. If no results, suggest 2-3 alternative search terms. +5. **Never hallucinate results.** Only present what the MCP tools actually return. diff --git a/plugin/opencode/commands/remember.md b/plugin/opencode/commands/remember.md new file mode 100644 index 0000000..196fc00 --- /dev/null +++ b/plugin/opencode/commands/remember.md @@ -0,0 +1,19 @@ +Explicitly save an insight, decision, or learning to agentmemory for future sessions. Wraps the `memory_save` MCP tool. + +## Usage + +``` +/remember [what to remember] +``` + +## Instructions + +1. Analyze what needs to be remembered — extract the core insight, decision, or fact. +2. Extract 2-5 searchable concepts (lowercased keyword phrases). Prefer specific terms ("jwt-refresh-rotation" over "auth"). +3. Extract relevant file paths the memory references. +4. Call `memory_save` with: + - `content` — full text to remember (preserve user's phrasing) + - `concepts` — extracted concept list + - `files` — extracted file list (empty array if none) + - `type` — choose from: pattern, preference, architecture, bug, workflow, fact +5. Confirm the save and show the concepts tagged so the user knows retrieval terms. diff --git a/plugin/opencode/plugin.json b/plugin/opencode/plugin.json new file mode 100644 index 0000000..1472752 --- /dev/null +++ b/plugin/opencode/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "agentmemory-capture", + "version": "0.9.20", + "description": "OpenCode plugin for agentmemory — full Claude Code hook parity: session lifecycle (create/idle/status/compacted/update/diff/delete/error), messages & prompts (chat.message, message.updated user+assistant, message.removed), tool lifecycle (ToolPart states with timing), part tracking (subtask, step-finish, reasoning, file, patch, compaction, agent, retry), file enrichment pipeline, permissions, task tracking (w/ priority), commands, config & model tracking. 22 hooks, 2 slash commands.", + "author": { + "name": "Rohit Ghumare", + "url": "https://github.com/rohitg00" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/rohitg00/agentmemory", + "repository": "https://github.com/rohitg00/agentmemory" +} diff --git a/plugin/plugin.json b/plugin/plugin.json new file mode 100644 index 0000000..9d0751a --- /dev/null +++ b/plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "agentmemory", + "version": "0.9.27", + "description": "Persistent memory for AI coding agents -- captures tool usage, compresses via LLM, injects context into future sessions. 12 hooks, 53 MCP tools, 15 skills, real-time viewer.", + "author": { + "name": "Rohit Ghumare", + "url": "https://github.com/rohitg00" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/rohitg00/agentmemory", + "repository": "https://github.com/rohitg00/agentmemory", + "skills": "skills/", + "mcpServers": ".mcp.copilot.json", + "hooks": "hooks/hooks.copilot.json" +} diff --git a/plugin/scripts/diagnostics.mjs b/plugin/scripts/diagnostics.mjs new file mode 100644 index 0000000..96d6dad --- /dev/null +++ b/plugin/scripts/diagnostics.mjs @@ -0,0 +1,551 @@ +//#region src/state/schema.ts +const KV = { + sessions: "mem:sessions", + observations: (sessionId) => `mem:obs:${sessionId}`, + memories: "mem:memories", + summaries: "mem:summaries", + config: "mem:config", + metrics: "mem:metrics", + health: "mem:health", + embeddings: (obsId) => `mem:emb:${obsId}`, + bm25Index: "mem:index:bm25", + relations: "mem:relations", + profiles: "mem:profiles", + claudeBridge: "mem:claude-bridge", + graphNodes: "mem:graph:nodes", + graphEdges: "mem:graph:edges", + semantic: "mem:semantic", + procedural: "mem:procedural", + teamShared: (teamId) => `mem:team:${teamId}:shared`, + teamUsers: (teamId, userId) => `mem:team:${teamId}:users:${userId}`, + teamProfile: (teamId) => `mem:team:${teamId}:profile`, + audit: "mem:audit", + actions: "mem:actions", + actionEdges: "mem:action-edges", + leases: "mem:leases", + routines: "mem:routines", + routineRuns: "mem:routine-runs", + signals: "mem:signals", + checkpoints: "mem:checkpoints", + mesh: "mem:mesh", + sketches: "mem:sketches", + facets: "mem:facets", + sentinels: "mem:sentinels", + crystals: "mem:crystals" +}; + +//#endregion +//#region src/state/keyed-mutex.ts +const locks = /* @__PURE__ */ new Map(); +function withKeyedLock(key, fn) { + const next = (locks.get(key) ?? Promise.resolve()).then(fn, fn); + const cleanup = next.then(() => {}, () => {}); + locks.set(key, cleanup); + cleanup.then(() => { + if (locks.get(key) === cleanup) locks.delete(key); + }); + return next; +} + +//#endregion +//#region src/functions/diagnostics.ts +const ALL_CATEGORIES = [ + "actions", + "leases", + "sentinels", + "sketches", + "signals", + "sessions", + "memories", + "mesh" +]; +const TWENTY_FOUR_HOURS_MS = 1440 * 60 * 1e3; +const ONE_HOUR_MS = 3600 * 1e3; +function registerDiagnosticsFunction(sdk, kv) { + sdk.registerFunction("mem::diagnose", async (data) => { + const categories = data.categories && data.categories.length > 0 ? data.categories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES; + const checks = []; + const now = Date.now(); + if (categories.includes("actions")) { + const actions = await kv.list(KV.actions); + const allEdges = await kv.list(KV.actionEdges); + const leases = await kv.list(KV.leases); + const actionMap = new Map(actions.map((a) => [a.id, a])); + for (const action of actions) { + if (action.status === "active") { + if (!leases.some((l) => l.actionId === action.id && l.status === "active" && new Date(l.expiresAt).getTime() > now)) checks.push({ + name: `active-no-lease:${action.id}`, + category: "actions", + status: "warn", + message: `Action "${action.title}" is active but has no active lease`, + fixable: false + }); + } + if (action.status === "blocked") { + const deps = allEdges.filter((e) => e.sourceActionId === action.id && e.type === "requires"); + if (deps.length > 0) { + if (deps.every((d) => { + const target = actionMap.get(d.targetActionId); + return target && target.status === "done"; + })) checks.push({ + name: `blocked-deps-done:${action.id}`, + category: "actions", + status: "fail", + message: `Action "${action.title}" is blocked but all dependencies are done`, + fixable: true + }); + } + } + if (action.status === "pending") { + const deps = allEdges.filter((e) => e.sourceActionId === action.id && e.type === "requires"); + if (deps.length > 0) { + if (deps.some((d) => { + const target = actionMap.get(d.targetActionId); + return !target || target.status !== "done"; + })) checks.push({ + name: `pending-unsatisfied-deps:${action.id}`, + category: "actions", + status: "fail", + message: `Action "${action.title}" is pending but has unsatisfied dependencies`, + fixable: true + }); + } + } + } + if (!checks.some((c) => c.category === "actions" && c.status !== "pass")) checks.push({ + name: "actions-ok", + category: "actions", + status: "pass", + message: `All ${actions.length} actions are consistent`, + fixable: false + }); + } + if (categories.includes("leases")) { + const leases = await kv.list(KV.leases); + const actions = await kv.list(KV.actions); + const actionIds = new Set(actions.map((a) => a.id)); + let leaseIssues = 0; + for (const lease of leases) { + if (lease.status === "active" && new Date(lease.expiresAt).getTime() <= now) { + checks.push({ + name: `expired-lease:${lease.id}`, + category: "leases", + status: "fail", + message: `Lease ${lease.id} for action ${lease.actionId} expired at ${lease.expiresAt}`, + fixable: true + }); + leaseIssues++; + } + if (!actionIds.has(lease.actionId)) { + checks.push({ + name: `orphaned-lease:${lease.id}`, + category: "leases", + status: "fail", + message: `Lease ${lease.id} references non-existent action ${lease.actionId}`, + fixable: true + }); + leaseIssues++; + } + } + if (leaseIssues === 0) checks.push({ + name: "leases-ok", + category: "leases", + status: "pass", + message: `All ${leases.length} leases are healthy`, + fixable: false + }); + } + if (categories.includes("sentinels")) { + const sentinels = await kv.list(KV.sentinels); + const actions = await kv.list(KV.actions); + const actionIds = new Set(actions.map((a) => a.id)); + let sentinelIssues = 0; + for (const sentinel of sentinels) { + if (sentinel.status === "watching" && sentinel.expiresAt && new Date(sentinel.expiresAt).getTime() <= now) { + checks.push({ + name: `expired-sentinel:${sentinel.id}`, + category: "sentinels", + status: "fail", + message: `Sentinel "${sentinel.name}" expired at ${sentinel.expiresAt}`, + fixable: true + }); + sentinelIssues++; + } + for (const actionId of sentinel.linkedActionIds) if (!actionIds.has(actionId)) { + checks.push({ + name: `sentinel-missing-action:${sentinel.id}:${actionId}`, + category: "sentinels", + status: "warn", + message: `Sentinel "${sentinel.name}" references non-existent action ${actionId}`, + fixable: false + }); + sentinelIssues++; + } + } + if (sentinelIssues === 0) checks.push({ + name: "sentinels-ok", + category: "sentinels", + status: "pass", + message: `All ${sentinels.length} sentinels are healthy`, + fixable: false + }); + } + if (categories.includes("sketches")) { + const sketches = await kv.list(KV.sketches); + let sketchIssues = 0; + for (const sketch of sketches) if (sketch.status === "active" && new Date(sketch.expiresAt).getTime() <= now) { + checks.push({ + name: `expired-sketch:${sketch.id}`, + category: "sketches", + status: "fail", + message: `Sketch "${sketch.title}" expired at ${sketch.expiresAt}`, + fixable: true + }); + sketchIssues++; + } + if (sketchIssues === 0) checks.push({ + name: "sketches-ok", + category: "sketches", + status: "pass", + message: `All ${sketches.length} sketches are healthy`, + fixable: false + }); + } + if (categories.includes("signals")) { + const signals = await kv.list(KV.signals); + let signalIssues = 0; + for (const signal of signals) if (signal.expiresAt && new Date(signal.expiresAt).getTime() <= now) { + checks.push({ + name: `expired-signal:${signal.id}`, + category: "signals", + status: "fail", + message: `Signal from "${signal.from}" expired at ${signal.expiresAt}`, + fixable: true + }); + signalIssues++; + } + if (signalIssues === 0) checks.push({ + name: "signals-ok", + category: "signals", + status: "pass", + message: `All ${signals.length} signals are healthy`, + fixable: false + }); + } + if (categories.includes("sessions")) { + const sessions = await kv.list(KV.sessions); + let sessionIssues = 0; + for (const session of sessions) if (session.status === "active" && now - new Date(session.startedAt).getTime() > TWENTY_FOUR_HOURS_MS) { + checks.push({ + name: `abandoned-session:${session.id}`, + category: "sessions", + status: "warn", + message: `Session ${session.id} has been active for over 24 hours`, + fixable: false + }); + sessionIssues++; + } + if (sessionIssues === 0) checks.push({ + name: "sessions-ok", + category: "sessions", + status: "pass", + message: `All ${sessions.length} sessions are healthy`, + fixable: false + }); + } + if (categories.includes("memories")) { + const memories = await kv.list(KV.memories); + const memoryIds = new Set(memories.map((m) => m.id)); + const supersededBy = /* @__PURE__ */ new Map(); + let memoryIssues = 0; + for (const memory of memories) if (memory.supersedes && memory.supersedes.length > 0) for (const sid of memory.supersedes) { + if (!memoryIds.has(sid)) { + checks.push({ + name: `memory-missing-supersedes:${memory.id}:${sid}`, + category: "memories", + status: "warn", + message: `Memory "${memory.title}" supersedes non-existent memory ${sid}`, + fixable: false + }); + memoryIssues++; + } + supersededBy.set(sid, memory.id); + } + for (const memory of memories) if (memory.isLatest && supersededBy.has(memory.id)) { + checks.push({ + name: `memory-stale-latest:${memory.id}`, + category: "memories", + status: "fail", + message: `Memory "${memory.title}" has isLatest=true but is superseded by ${supersededBy.get(memory.id)}`, + fixable: true + }); + memoryIssues++; + } + if (memoryIssues === 0) checks.push({ + name: "memories-ok", + category: "memories", + status: "pass", + message: `All ${memories.length} memories are consistent`, + fixable: false + }); + } + if (categories.includes("mesh")) { + const peers = await kv.list(KV.mesh); + let meshIssues = 0; + for (const peer of peers) { + if (peer.lastSyncAt && now - new Date(peer.lastSyncAt).getTime() > ONE_HOUR_MS) { + checks.push({ + name: `stale-peer:${peer.id}`, + category: "mesh", + status: "warn", + message: `Peer "${peer.name}" last synced over 1 hour ago`, + fixable: false + }); + meshIssues++; + } + if (peer.status === "error") { + checks.push({ + name: `error-peer:${peer.id}`, + category: "mesh", + status: "warn", + message: `Peer "${peer.name}" is in error state`, + fixable: false + }); + meshIssues++; + } + } + if (meshIssues === 0) checks.push({ + name: "mesh-ok", + category: "mesh", + status: "pass", + message: `All ${peers.length} mesh peers are healthy`, + fixable: false + }); + } + return { + success: true, + checks, + summary: { + pass: checks.filter((c) => c.status === "pass").length, + warn: checks.filter((c) => c.status === "warn").length, + fail: checks.filter((c) => c.status === "fail").length, + fixable: checks.filter((c) => c.fixable).length + } + }; + }); + sdk.registerFunction("mem::heal", async (data) => { + const dryRun = data.dryRun ?? false; + const categories = data.categories && data.categories.length > 0 ? data.categories.filter((c) => ALL_CATEGORIES.includes(c)) : ALL_CATEGORIES; + let fixed = 0; + let skipped = 0; + const details = []; + const now = Date.now(); + if (categories.includes("actions")) { + const actions = await kv.list(KV.actions); + const allEdges = await kv.list(KV.actionEdges); + const actionMap = new Map(actions.map((a) => [a.id, a])); + for (const action of actions) { + if (action.status === "blocked") { + const deps = allEdges.filter((e) => e.sourceActionId === action.id && e.type === "requires"); + if (deps.length > 0) { + if (deps.every((d) => { + const target = actionMap.get(d.targetActionId); + return target && target.status === "done"; + })) { + if (dryRun) { + details.push(`[dry-run] Would unblock action "${action.title}" (${action.id})`); + fixed++; + continue; + } + if (await withKeyedLock(`mem:action:${action.id}`, async () => { + const fresh = await kv.get(KV.actions, action.id); + if (!fresh || fresh.status !== "blocked") return false; + const freshDeps = (await kv.list(KV.actionEdges)).filter((e) => e.sourceActionId === fresh.id && e.type === "requires"); + const freshActions = await kv.list(KV.actions); + const freshMap = new Map(freshActions.map((a) => [a.id, a])); + if (!freshDeps.every((d) => { + const target = freshMap.get(d.targetActionId); + return target && target.status === "done"; + })) return false; + fresh.status = "pending"; + fresh.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); + await kv.set(KV.actions, fresh.id, fresh); + return true; + })) { + details.push(`Unblocked action "${action.title}" (${action.id})`); + fixed++; + } else skipped++; + } + } + } + if (action.status === "pending") { + const deps = allEdges.filter((e) => e.sourceActionId === action.id && e.type === "requires"); + if (deps.length > 0) { + if (deps.some((d) => { + const target = actionMap.get(d.targetActionId); + return !target || target.status !== "done"; + })) { + if (dryRun) { + details.push(`[dry-run] Would block action "${action.title}" (${action.id})`); + fixed++; + continue; + } + if (await withKeyedLock(`mem:action:${action.id}`, async () => { + const fresh = await kv.get(KV.actions, action.id); + if (!fresh || fresh.status !== "pending") return false; + const freshDeps = (await kv.list(KV.actionEdges)).filter((e) => e.sourceActionId === fresh.id && e.type === "requires"); + const freshActions = await kv.list(KV.actions); + const freshMap = new Map(freshActions.map((a) => [a.id, a])); + if (!freshDeps.some((d) => { + const target = freshMap.get(d.targetActionId); + return !target || target.status !== "done"; + })) return false; + fresh.status = "blocked"; + fresh.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); + await kv.set(KV.actions, fresh.id, fresh); + return true; + })) { + details.push(`Blocked action "${action.title}" (${action.id})`); + fixed++; + } else skipped++; + } + } + } + } + } + if (categories.includes("leases")) { + const leases = await kv.list(KV.leases); + const actions = await kv.list(KV.actions); + const actionIds = new Set(actions.map((a) => a.id)); + for (const lease of leases) { + if (lease.status === "active" && new Date(lease.expiresAt).getTime() <= now) { + if (dryRun) { + details.push(`[dry-run] Would expire lease ${lease.id} for action ${lease.actionId}`); + fixed++; + continue; + } + if (await withKeyedLock(`mem:action:${lease.actionId}`, async () => { + const fresh = await kv.get(KV.leases, lease.id); + if (!fresh || fresh.status !== "active" || new Date(fresh.expiresAt).getTime() > Date.now()) return false; + fresh.status = "expired"; + await kv.set(KV.leases, fresh.id, fresh); + const action = await kv.get(KV.actions, fresh.actionId); + if (action && action.status === "active" && action.assignedTo === fresh.agentId) { + action.status = "pending"; + action.assignedTo = void 0; + action.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); + await kv.set(KV.actions, action.id, action); + } + return true; + })) { + details.push(`Expired lease ${lease.id} for action ${lease.actionId}`); + fixed++; + } else skipped++; + continue; + } + if (!actionIds.has(lease.actionId)) { + if (dryRun) { + details.push(`[dry-run] Would delete orphaned lease ${lease.id}`); + fixed++; + continue; + } + await kv.delete(KV.leases, lease.id); + details.push(`Deleted orphaned lease ${lease.id}`); + fixed++; + } + } + } + if (categories.includes("sentinels")) { + const sentinels = await kv.list(KV.sentinels); + for (const sentinel of sentinels) if (sentinel.status === "watching" && sentinel.expiresAt && new Date(sentinel.expiresAt).getTime() <= now) { + if (dryRun) { + details.push(`[dry-run] Would expire sentinel "${sentinel.name}" (${sentinel.id})`); + fixed++; + continue; + } + if (await withKeyedLock(`mem:sentinel:${sentinel.id}`, async () => { + const fresh = await kv.get(KV.sentinels, sentinel.id); + if (!fresh || fresh.status !== "watching") return false; + if (!fresh.expiresAt || new Date(fresh.expiresAt).getTime() > Date.now()) return false; + fresh.status = "expired"; + await kv.set(KV.sentinels, fresh.id, fresh); + return true; + })) { + details.push(`Expired sentinel "${sentinel.name}" (${sentinel.id})`); + fixed++; + } else skipped++; + } + } + if (categories.includes("sketches")) { + const sketches = await kv.list(KV.sketches); + for (const sketch of sketches) if (sketch.status === "active" && new Date(sketch.expiresAt).getTime() <= now) { + if (dryRun) { + details.push(`[dry-run] Would discard expired sketch "${sketch.title}" (${sketch.id})`); + fixed++; + continue; + } + if (await withKeyedLock(`mem:sketch:${sketch.id}`, async () => { + const fresh = await kv.get(KV.sketches, sketch.id); + if (!fresh || fresh.status !== "active" || new Date(fresh.expiresAt).getTime() > Date.now()) return false; + const allEdges = await kv.list(KV.actionEdges); + const actionIdSet = new Set(fresh.actionIds); + for (const edge of allEdges) if (actionIdSet.has(edge.sourceActionId) || actionIdSet.has(edge.targetActionId)) await kv.delete(KV.actionEdges, edge.id); + for (const actionId of fresh.actionIds) await kv.delete(KV.actions, actionId); + fresh.status = "discarded"; + fresh.discardedAt = (/* @__PURE__ */ new Date()).toISOString(); + await kv.set(KV.sketches, fresh.id, fresh); + return true; + })) { + details.push(`Discarded expired sketch "${sketch.title}" (${sketch.id})`); + fixed++; + } else skipped++; + } + } + if (categories.includes("signals")) { + const signals = await kv.list(KV.signals); + for (const signal of signals) if (signal.expiresAt && new Date(signal.expiresAt).getTime() <= now) { + if (dryRun) { + details.push(`[dry-run] Would delete expired signal ${signal.id}`); + fixed++; + continue; + } + await kv.delete(KV.signals, signal.id); + details.push(`Deleted expired signal ${signal.id}`); + fixed++; + } + } + if (categories.includes("memories")) { + const memories = await kv.list(KV.memories); + const supersededBy = /* @__PURE__ */ new Map(); + for (const memory of memories) if (memory.supersedes && memory.supersedes.length > 0) for (const sid of memory.supersedes) supersededBy.set(sid, memory.id); + for (const memory of memories) if (memory.isLatest && supersededBy.has(memory.id)) { + if (dryRun) { + details.push(`[dry-run] Would set isLatest=false on memory "${memory.title}" (${memory.id})`); + fixed++; + continue; + } + if (await withKeyedLock(`mem:memory:${memory.id}`, async () => { + const fresh = await kv.get(KV.memories, memory.id); + if (!fresh || !fresh.isLatest) return false; + fresh.isLatest = false; + fresh.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); + await kv.set(KV.memories, fresh.id, fresh); + return true; + })) { + details.push(`Set isLatest=false on memory "${memory.title}" (${memory.id})`); + fixed++; + } else skipped++; + } + } + return { + success: true, + fixed, + skipped, + details + }; + }); +} + +//#endregion +export { registerDiagnosticsFunction }; +//# sourceMappingURL=diagnostics.mjs.map \ No newline at end of file diff --git a/plugin/scripts/notification.mjs b/plugin/scripts/notification.mjs new file mode 100755 index 0000000..3967158 --- /dev/null +++ b/plugin/scripts/notification.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/notification.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const notificationType = data.notification_type ?? data.notificationType; + if (notificationType !== "permission_prompt") return; + const rawSessionId = data.session_id ?? data.sessionId; + const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : "unknown"; + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "notification", + sessionId, + project: resolveProject(data.cwd), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + notification_type: notificationType, + title: data.title, + message: data.message + } + }), + signal: AbortSignal.timeout(2e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=notification.mjs.map \ No newline at end of file diff --git a/plugin/scripts/post-commit.mjs b/plugin/scripts/post-commit.mjs new file mode 100755 index 0000000..8552cd6 --- /dev/null +++ b/plugin/scripts/post-commit.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +//#region src/hooks/post-commit.ts +const exec = promisify(execFile); +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +const TIMEOUT_MS = 1500; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function git(args, cwd) { + try { + const { stdout } = await exec("git", args, { + cwd, + timeout: 1500 + }); + return stdout.trim(); + } catch { + return null; + } +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data = {}; + if (input.trim()) try { + data = JSON.parse(input); + } catch {} + if (isSdkChildContext(data)) return; + const cwd = data.cwd || process.env["AGENTMEMORY_CWD"] || process.cwd(); + const sessionId = data.session_id || process.env["AGENTMEMORY_SESSION_ID"] || void 0; + const sha = process.env["AGENTMEMORY_COMMIT_SHA"] || await git(["rev-parse", "HEAD"], cwd); + if (!sha) return; + const branch = await git([ + "rev-parse", + "--abbrev-ref", + "HEAD" + ], cwd); + const repo = await git([ + "config", + "--get", + "remote.origin.url" + ], cwd); + const message = await git([ + "log", + "-1", + "--pretty=%B", + sha + ], cwd); + const author = await git([ + "log", + "-1", + "--pretty=%an <%ae>", + sha + ], cwd); + const authoredAt = await git([ + "log", + "-1", + "--pretty=%aI", + sha + ], cwd); + const filesRaw = await git([ + "diff-tree", + "--no-commit-id", + "--name-only", + "-r", + sha + ], cwd); + const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : void 0; + const body = { + sessionId, + sha, + branch: branch || void 0, + repo: repo || void 0, + message: message || void 0, + author: author || void 0, + authoredAt: authoredAt || void 0, + files + }; + try { + await fetch(`${REST_URL}/agentmemory/session/commit`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS) + }); + } catch {} +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=post-commit.mjs.map \ No newline at end of file diff --git a/plugin/scripts/post-tool-failure.mjs b/plugin/scripts/post-tool-failure.mjs new file mode 100755 index 0000000..6fdad8d --- /dev/null +++ b/plugin/scripts/post-tool-failure.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/post-tool-failure.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + if (data.is_interrupt || data.isInterrupt) return; + const sessionId = data.session_id || data.sessionId || "unknown"; + const toolName = data.tool_name ?? data.toolName; + const toolInput = data.tool_input ?? data.toolArgs; + const error = data.error ?? data.errorMessage; + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "post_tool_failure", + sessionId, + project: resolveProject(data.cwd), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + tool_name: toolName, + tool_input: typeof toolInput === "string" ? toolInput.slice(0, 4e3) : JSON.stringify(toolInput ?? "").slice(0, 4e3), + error: typeof error === "string" ? error.slice(0, 4e3) : JSON.stringify(error ?? "").slice(0, 4e3) + } + }), + signal: AbortSignal.timeout(3e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=post-tool-failure.mjs.map \ No newline at end of file diff --git a/plugin/scripts/post-tool-use.mjs b/plugin/scripts/post-tool-use.mjs new file mode 100755 index 0000000..b4aef9c --- /dev/null +++ b/plugin/scripts/post-tool-use.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/post-tool-use.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const sessionId = data.session_id || data.sessionId || "unknown"; + const toolName = data.tool_name ?? data.toolName; + const toolInput = data.tool_input ?? data.toolArgs; + const { imageData, cleanOutput } = extractImageData(toolOutput(data)); + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "post_tool_use", + sessionId, + project: resolveProject(data.cwd), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + tool_name: toolName, + tool_input: toolInput, + tool_output: truncate(cleanOutput, 8e3), + ...imageData ? { image_data: imageData } : {} + } + }), + signal: AbortSignal.timeout(3e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} +function toolOutput(data) { + if (data.tool_response !== void 0) return data.tool_response; + if (data.tool_output !== void 0) return data.tool_output; + const result = data.tool_result ?? data.toolResult; + if (typeof result === "object" && result !== null) { + const obj = result; + return obj.text_result_for_llm ?? obj.textResultForLlm ?? result; + } + return result; +} +function isBase64Image(val) { + return typeof val === "string" && (val.startsWith("data:image/") || val.startsWith("iVBORw0KGgo") || val.startsWith("/9j/")); +} +function extractImageData(output) { + if (isBase64Image(output)) return { + imageData: output, + cleanOutput: "[image data extracted]" + }; + if (typeof output === "object" && output !== null && !Array.isArray(output)) { + const obj = output; + let imageData; + const clean = {}; + for (const [key, val] of Object.entries(obj)) if (!imageData && isBase64Image(val)) { + imageData = val; + clean[key] = "[image data extracted]"; + } else clean[key] = val; + return { + imageData, + cleanOutput: clean + }; + } + return { + imageData: void 0, + cleanOutput: output + }; +} +function truncate(value, max) { + if (typeof value === "string" && value.length > max) return value.slice(0, max) + "\n[...truncated]"; + if (typeof value === "object" && value !== null) { + const str = JSON.stringify(value); + if (str.length > max) return str.slice(0, max) + "...[truncated]"; + return value; + } + return value; +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=post-tool-use.mjs.map \ No newline at end of file diff --git a/plugin/scripts/pre-compact.mjs b/plugin/scripts/pre-compact.mjs new file mode 100755 index 0000000..0afdcb0 --- /dev/null +++ b/plugin/scripts/pre-compact.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/pre-compact.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const sessionId = data.session_id || data.sessionId || "unknown"; + const project = resolveProject(data.cwd); + if (process.env["CLAUDE_MEMORY_BRIDGE"] === "true") try { + await fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + signal: AbortSignal.timeout(5e3) + }); + } catch {} + try { + const res = await fetch(`${REST_URL}/agentmemory/context`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sessionId, + project, + budget: 1500 + }), + signal: AbortSignal.timeout(5e3) + }); + if (res.ok) { + const result = await res.json(); + if (result.context) process.stdout.write(result.context); + } + } catch {} +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=pre-compact.mjs.map \ No newline at end of file diff --git a/plugin/scripts/pre-tool-use.mjs b/plugin/scripts/pre-tool-use.mjs new file mode 100755 index 0000000..d70c166 --- /dev/null +++ b/plugin/scripts/pre-tool-use.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +//#region src/hooks/pre-tool-use.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const INJECT_CONTEXT = process.env["AGENTMEMORY_INJECT_CONTEXT"] === "true"; +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + if (!INJECT_CONTEXT) return; + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const toolName = typeof data.tool_name === "string" ? data.tool_name : typeof data.toolName === "string" ? data.toolName : void 0; + if (!toolName) return; + const normalizedToolName = toolName.toLowerCase(); + if (![ + "edit", + "write", + "create", + "read", + "view", + "glob", + "grep" + ].includes(normalizedToolName)) return; + const rawToolInput = data.tool_input ?? data.toolArgs; + const toolInput = typeof rawToolInput === "object" && rawToolInput !== null && !Array.isArray(rawToolInput) ? rawToolInput : {}; + const files = []; + const fileKeys = normalizedToolName === "grep" ? ["path", "file"] : [ + "file_path", + "path", + "file", + "pattern" + ]; + for (const key of fileKeys) { + const val = toolInput[key]; + if (typeof val === "string" && val.length > 0) files.push(val); + } + if (files.length === 0) return; + const terms = []; + if (normalizedToolName === "grep" || normalizedToolName === "glob") { + const pattern = toolInput["pattern"]; + if (typeof pattern === "string" && pattern.length > 0) terms.push(pattern); + } + const rawSessionId = data.session_id || data.sessionId; + const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : "unknown"; + const project = typeof data.project === "string" && data.project.trim().length > 0 ? data.project.trim() : void 0; + try { + const res = await fetch(`${REST_URL}/agentmemory/enrich`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sessionId, + files, + terms, + toolName, + ...project !== void 0 && { project } + }), + signal: AbortSignal.timeout(2e3) + }); + if (res.ok) { + const result = await res.json(); + if (result.context) process.stdout.write(result.context); + } + } catch {} +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=pre-tool-use.mjs.map \ No newline at end of file diff --git a/plugin/scripts/prompt-submit.mjs b/plugin/scripts/prompt-submit.mjs new file mode 100755 index 0000000..1a4147e --- /dev/null +++ b/plugin/scripts/prompt-submit.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/prompt-submit.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const sessionId = data.session_id || data.sessionId || "unknown"; + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "prompt_submit", + sessionId, + project: resolveProject(data.cwd), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { prompt: data.prompt ?? data.userPrompt } + }), + signal: AbortSignal.timeout(3e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=prompt-submit.mjs.map \ No newline at end of file diff --git a/plugin/scripts/session-end.mjs b/plugin/scripts/session-end.mjs new file mode 100755 index 0000000..019149c --- /dev/null +++ b/plugin/scripts/session-end.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +//#region src/hooks/session-end.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const sessionId = data.session_id || data.sessionId || "unknown"; + fetch(`${REST_URL}/agentmemory/session/end`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId }), + signal: AbortSignal.timeout(3e4) + }).catch(() => {}); + if (process.env["CONSOLIDATION_ENABLED"] === "true") { + fetch(`${REST_URL}/agentmemory/crystals/auto`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ olderThanDays: 0 }), + signal: AbortSignal.timeout(6e4) + }).catch(() => {}); + fetch(`${REST_URL}/agentmemory/consolidate-pipeline`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + tier: "all", + force: true + }), + signal: AbortSignal.timeout(12e4) + }).catch(() => {}); + } + if (process.env["CLAUDE_MEMORY_BRIDGE"] === "true") fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, { + method: "POST", + headers: authHeaders(), + signal: AbortSignal.timeout(3e4) + }).catch(() => {}); + setTimeout(() => process.exit(0), 1500).unref(); +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=session-end.mjs.map \ No newline at end of file diff --git a/plugin/scripts/session-start.mjs b/plugin/scripts/session-start.mjs new file mode 100755 index 0000000..51b70eb --- /dev/null +++ b/plugin/scripts/session-start.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/session-start.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const INJECT_CONTEXT = process.env["AGENTMEMORY_INJECT_CONTEXT"] === "true"; +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +const INJECT_TIMEOUT_MS = 1500; +const REGISTER_TIMEOUT_MS = 800; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const sessionId = data.session_id || data.sessionId || `ses_${Date.now().toString(36)}`; + const cwd = data.cwd || process.cwd(); + const project = resolveProject(data.cwd); + const url = `${REST_URL}/agentmemory/session/start`; + const init = { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sessionId, + project, + cwd + }) + }; + if (!INJECT_CONTEXT) { + fetch(url, { + ...init, + signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS) + }).catch(() => {}); + return; + } + try { + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(INJECT_TIMEOUT_MS) + }); + if (res.ok) { + const result = await res.json(); + if (result.context) process.stdout.write(result.context); + } + } catch {} +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=session-start.mjs.map \ No newline at end of file diff --git a/plugin/scripts/stop.mjs b/plugin/scripts/stop.mjs new file mode 100755 index 0000000..03d30c6 --- /dev/null +++ b/plugin/scripts/stop.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +//#region src/hooks/stop.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + 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(), + body: JSON.stringify({ sessionId }), + signal: AbortSignal.timeout(5e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 1500).unref(); +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=stop.mjs.map \ No newline at end of file diff --git a/plugin/scripts/subagent-start.mjs b/plugin/scripts/subagent-start.mjs new file mode 100755 index 0000000..2359a1c --- /dev/null +++ b/plugin/scripts/subagent-start.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/subagent-start.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +const TIMEOUT_MS = 800; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const sessionId = data.session_id || data.sessionId || "unknown"; + const agentId = data.agent_id || data.agentName; + const agentType = data.agent_type || data.agentDisplayName || data.agentName; + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "subagent_start", + sessionId, + project: resolveProject(data.cwd), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + agent_id: agentId, + agent_type: agentType + } + }), + signal: AbortSignal.timeout(TIMEOUT_MS) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=subagent-start.mjs.map \ No newline at end of file diff --git a/plugin/scripts/subagent-stop.mjs b/plugin/scripts/subagent-stop.mjs new file mode 100755 index 0000000..2ba1b00 --- /dev/null +++ b/plugin/scripts/subagent-stop.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/subagent-stop.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const sessionId = data.session_id || data.sessionId || "unknown"; + const agentId = data.agent_id || data.agentName; + const agentType = data.agent_type || data.agentDisplayName || data.agentName; + const lastMsg = typeof data.last_assistant_message === "string" ? data.last_assistant_message.slice(0, 4e3) : ""; + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "subagent_stop", + sessionId, + project: resolveProject(data.cwd), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + agent_id: agentId, + agent_type: agentType, + last_message: lastMsg + } + }), + signal: AbortSignal.timeout(2e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=subagent-stop.mjs.map \ No newline at end of file diff --git a/plugin/scripts/task-completed.mjs b/plugin/scripts/task-completed.mjs new file mode 100755 index 0000000..478f068 --- /dev/null +++ b/plugin/scripts/task-completed.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +//#region src/hooks/_project.ts +function resolveProject(cwd) { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: [ + "ignore", + "pipe", + "ignore" + ], + timeout: 500 + }).toString().trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} + +//#endregion +//#region src/hooks/task-completed.ts +function isSdkChildContext(payload) { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return payload.entrypoint === "sdk-ts"; +} +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +function authHeaders() { + const h = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} +async function main() { + let input = ""; + for await (const chunk of process.stdin) input += chunk; + let data; + try { + data = JSON.parse(input); + } catch { + return; + } + if (isSdkChildContext(data)) return; + const sessionId = data.session_id || "unknown"; + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "task_completed", + sessionId, + project: resolveProject(data.cwd), + cwd: data.cwd || process.cwd(), + timestamp: (/* @__PURE__ */ new Date()).toISOString(), + data: { + task_id: data.task_id, + task_subject: data.task_subject, + task_description: typeof data.task_description === "string" ? data.task_description.slice(0, 2e3) : "", + teammate_name: data.teammate_name, + team_name: data.team_name + } + }), + signal: AbortSignal.timeout(2e3) + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} +main(); + +//#endregion +export { }; +//# sourceMappingURL=task-completed.mjs.map \ No newline at end of file diff --git a/plugin/skills/_shared/TROUBLESHOOTING.md b/plugin/skills/_shared/TROUBLESHOOTING.md new file mode 100644 index 0000000..ee082d0 --- /dev/null +++ b/plugin/skills/_shared/TROUBLESHOOTING.md @@ -0,0 +1,38 @@ +# Troubleshooting agentmemory skills + +Shared recovery steps for all user-invocable agentmemory skills. Each skill's +Troubleshooting section points here instead of duplicating the block. + +## "MCP tool not available" + +If a `memory_*` MCP tool does not appear, the stdio MCP shim never started. +Walk these in order: + +1. Run `/plugin list` in the host and confirm `agentmemory` shows as enabled. +2. Restart the host. The plugin's `.mcp.json` is only read on startup, so a + freshly installed or re-enabled plugin will not register tools mid-session. +3. Check `/mcp` and confirm the `agentmemory` server shows a live connection. + +## REST fallback + +When the MCP tools stay unavailable but the daemon is running, call the REST +API directly: + +1. Set `AGENTMEMORY_URL` to the daemon base URL (default `http://localhost:3111`). +2. Add `Authorization: Bearer $AGENTMEMORY_SECRET` ONLY when `AGENTMEMORY_SECRET` + is set. The default localhost daemon is open and rejects a stray header. + +Endpoint map by skill: + +| Skill | REST call | +| --------------- | --------------------------------------------------------------- | +| remember | `POST /agentmemory/remember` | +| recall | `POST /agentmemory/smart-search` | +| recap | `GET /agentmemory/sessions` + `POST /agentmemory/smart-search` | +| handoff | `GET /agentmemory/sessions` + `POST /agentmemory/smart-search` | +| session-history | `GET /agentmemory/sessions` | +| commit-context | `GET /agentmemory/session/by-commit?sha=` | +| commit-history | `GET /agentmemory/commits` (URL-encode every query param) | + +The daemon reads `.mcp.json` on startup only, so any port or auth change needs a +restart before either transport sees it. diff --git a/plugin/skills/agentmemory-agents/REFERENCE.md b/plugin/skills/agentmemory-agents/REFERENCE.md new file mode 100644 index 0000000..d1d8295 --- /dev/null +++ b/plugin/skills/agentmemory-agents/REFERENCE.md @@ -0,0 +1,28 @@ +# agentmemory connect adapters reference + +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. 18 adapters: + +| Agent | Name | Protocol | +| --- | --- | --- | +| Antigravity | `antigravity` | Using MCP via mcp_config.json. Antigravity replaces Gemini CLI (sunset 2026-06-18). | +| Claude Code | `claude-code` | Using MCP. Hooks are also available, see https://github.com/rohitg00/agentmemory#claude-code-one-block-paste-it. | +| Cline | `cline` | Using MCP via ~/.cline/mcp.json (CLI). VS Code users: add the same block via Cline Settings → MCP Servers → Edit JSON. | +| Codex CLI | `codex` | Using MCP. Hooks ship via the Codex plugin; on Codex Desktop, also pass --with-hooks to install the global hooks.json workaround for openai/codex#16430. | +| Continue | `continue` | Using MCP via ~/.continue/config.yaml (preferred) or config.json (legacy, only when no yaml). | +| 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. | +| 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. | +| 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-agents/SKILL.md b/plugin/skills/agentmemory-agents/SKILL.md new file mode 100644 index 0000000..e4cda49 --- /dev/null +++ b/plugin/skills/agentmemory-agents/SKILL.md @@ -0,0 +1,34 @@ +--- +name: agentmemory-agents +description: How agentmemory wires into host coding agents via the connect command. Use when installing agentmemory into a specific agent, when asked which agents are supported, or when a connect adapter writes the wrong config path. +user-invocable: false +--- + +`agentmemory connect ` merges the memory server into a host agent's config and preserves any existing servers. REST is the underlying protocol; for MCP-only hosts the adapter wires the stdio MCP bridge. + +## Quick start + +```bash +agentmemory connect claude-code # or cursor, codex, gemini-cli, ... +``` + +After wiring, restart the host or run its MCP reload (for example `/mcp` in Claude Code) so it picks up the server. Then confirm the agent lists agentmemory's tools. + +## Workflow + +1. Detect the calling agent. If unknown, default to `claude-code`. +2. Run `agentmemory connect ` using a name from the table in REFERENCE.md. +3. Verify: the host should show the full tool set with a server running. Only 7 tools means the MCP shim could not reach a server (see ../_shared/TROUBLESHOOTING.md). + +## Notes + +- The action skills (remember, recall, and the rest) are installed separately with `npx skills add rohitg00/agentmemory`. `connect` makes tools available; skills teach the agent when to use them. +- Windows: use WSL2. Native Windows runs the server but `connect` is not supported there. + +## See also + +- agentmemory-mcp-tools, agentmemory-rest-api, agentmemory-hooks. + +## Reference + +The full adapter list with display names and protocol notes lives in REFERENCE.md, generated from `src/cli/connect/`. diff --git a/plugin/skills/agentmemory-architecture/SKILL.md b/plugin/skills/agentmemory-architecture/SKILL.md new file mode 100644 index 0000000..431c260 --- /dev/null +++ b/plugin/skills/agentmemory-architecture/SKILL.md @@ -0,0 +1,33 @@ +--- +name: agentmemory-architecture +description: How agentmemory is built, the iii engine primitives it runs on, its storage model, ports, and the viewer. Use when reasoning about how memory is stored or retrieved end to end, when extending the system, or when answering how agentmemory works under the hood. +user-invocable: false +--- + +agentmemory is a memory server for coding agents. It runs locally, captures observations, indexes them for hybrid retrieval, and serves them back over REST and MCP. It is built on the iii engine. + +## iii primitives + +Everything is a function, a trigger, or worker state on the iii engine. There is no separate plugin system; the worker registers functions (`mem::*`) and HTTP triggers (`api::*`) and the engine routes calls. agentmemory does not bypass iii; new capability is a new function plus a trigger. + +## Retrieval model + +Recall is hybrid: BM25 keyword search plus vector similarity plus graph expansion over linked concepts. The default install needs no API key because embeddings run on-device and BM25 needs none. An LLM provider only adds richer summaries and auto-injection, both opt-in. + +## Storage and lifecycle + +Memories carry content, concepts, files, importance, and timestamps, grouped into sessions and optionally linked to commits. A lifecycle of capture, compress, consolidate, and forget keeps the store useful over time rather than letting it grow unbounded. + +## Ports + +REST is the anchor at 3111. Streams = N+1 (3112), viewer = N+2 (3113), engine = N+46023 (49134). `--instance N` shifts the whole block by N*100. + +## Viewer + +A real-time web viewer at `http://localhost:3113` shows memory building as sessions run. Useful for demos and for confirming capture is working. + +## See also + +- agentmemory-mcp-tools and agentmemory-rest-api for the surfaces. +- agentmemory-hooks for automatic capture. +- agentmemory-config for ports and feature flags. diff --git a/plugin/skills/agentmemory-config/REFERENCE.md b/plugin/skills/agentmemory-config/REFERENCE.md new file mode 100644 index 0000000..08c873a --- /dev/null +++ b/plugin/skills/agentmemory-config/REFERENCE.md @@ -0,0 +1,42 @@ +# agentmemory configuration reference + +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). 34 recognized variables: + +- `AGENTMEMORY_AGENT_SCOPE` +- `AGENTMEMORY_ALLOW_AGENT_SDK` +- `AGENTMEMORY_AUTO_COMPRESS` +- `AGENTMEMORY_COMMIT_SHA` +- `AGENTMEMORY_COPILOT_MCP_BLOCK` +- `AGENTMEMORY_CWD` +- `AGENTMEMORY_DEBUG` +- `AGENTMEMORY_DROP_STALE_INDEX` +- `AGENTMEMORY_EXPORT_ROOT` +- `AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS` +- `AGENTMEMORY_FORCE_PROXY` +- `AGENTMEMORY_GRAPH_WEIGHT` +- `AGENTMEMORY_III_CONFIG` +- `AGENTMEMORY_III_VERSION` +- `AGENTMEMORY_IMAGE_EMBEDDINGS` +- `AGENTMEMORY_IMAGE_STORE_MAX_BYTES` +- `AGENTMEMORY_INJECT_CONTEXT` +- `AGENTMEMORY_LLM_TIMEOUT_MS` +- `AGENTMEMORY_MCP_BLOCK` +- `AGENTMEMORY_PROBE_TIMEOUT_MS` +- `AGENTMEMORY_PROJECT_NAME` +- `AGENTMEMORY_PROVIDER` +- `AGENTMEMORY_REFLECT` +- `AGENTMEMORY_SDK_CHILD` +- `AGENTMEMORY_SECRET` +- `AGENTMEMORY_SESSION_ID` +- `AGENTMEMORY_SLOTS` +- `AGENTMEMORY_SUPPRESS_COST_WARNING` +- `AGENTMEMORY_TOOLS` +- `AGENTMEMORY_URL` +- `AGENTMEMORY_USE_DOCKER` +- `AGENTMEMORY_VERBOSE` +- `AGENTMEMORY_VIEWER_HOST` +- `AGENTMEMORY_VIEWER_URL` + diff --git a/plugin/skills/agentmemory-config/SKILL.md b/plugin/skills/agentmemory-config/SKILL.md new file mode 100644 index 0000000..aea78c9 --- /dev/null +++ b/plugin/skills/agentmemory-config/SKILL.md @@ -0,0 +1,37 @@ +--- +name: agentmemory-config +description: agentmemory configuration, environment variables, ports, and feature flags. Use when enabling a feature, changing ports, setting an API key, configuring auth, or explaining why a feature is off by default. +user-invocable: false +--- + +agentmemory reads configuration from the environment and from `~/.agentmemory/.env` (one `KEY=value` per line, no `export` prefix). Restart the server after changing it. + +## Quick start + +Enable richer memory and set a provider key in `~/.agentmemory/.env`: + +```env +ANTHROPIC_API_KEY=sk-ant-... +AGENTMEMORY_AUTO_COMPRESS=true +AGENTMEMORY_INJECT_CONTEXT=true +``` + +## Defaults worth knowing + +- No API key is required. Without one, agentmemory runs zero-LLM with BM25 plus local embeddings. +- Token-spending features ship OFF on purpose: `AGENTMEMORY_AUTO_COMPRESS` (LLM summaries) and `AGENTMEMORY_INJECT_CONTEXT` (auto context injection) both cost tokens proportional to tool-use frequency. +- Tool visibility: `AGENTMEMORY_TOOLS=all` (default) or `core` for the lean set. +- Auth: set `AGENTMEMORY_SECRET` to require `Authorization: Bearer` on the REST API. + +## Ports + +REST is the anchor at 3111. Streams = N+1 (3112), viewer = N+2 (3113), engine = N+46023 (49134). Relocate the whole block with `--port ` or `--instance `. + +## See also + +- agentmemory-rest-api for how the secret is used. +- agentmemory-architecture for the port quartet rationale. + +## Reference + +The full recognized-variable list lives in REFERENCE.md, generated by scanning `src/`. diff --git a/plugin/skills/agentmemory-hooks/REFERENCE.md b/plugin/skills/agentmemory-hooks/REFERENCE.md new file mode 100644 index 0000000..2cf2dce --- /dev/null +++ b/plugin/skills/agentmemory-hooks/REFERENCE.md @@ -0,0 +1,20 @@ +# agentmemory hooks reference + +Generated from `plugin/hooks/hooks.json`. Do not edit the block below by hand; run `npm run skills:gen` after changing the hook registration. + + +The Claude Code plugin registers hooks on 12 lifecycle events to capture observations automatically: + +- `Notification` +- `PostToolUse` +- `PostToolUseFailure` +- `PreCompact` +- `PreToolUse` +- `SessionEnd` +- `SessionStart` +- `Stop` +- `SubagentStart` +- `SubagentStop` +- `TaskCompleted` +- `UserPromptSubmit` + diff --git a/plugin/skills/agentmemory-hooks/SKILL.md b/plugin/skills/agentmemory-hooks/SKILL.md new file mode 100644 index 0000000..b8127f3 --- /dev/null +++ b/plugin/skills/agentmemory-hooks/SKILL.md @@ -0,0 +1,39 @@ +--- +name: agentmemory-hooks +description: The agentmemory plugin hooks that capture observations automatically across the agent session lifecycle. Use when explaining how memory gets captured without manual saves, when debugging missing observations, or when tuning what gets recorded. +user-invocable: false +--- + +The Claude Code plugin registers lifecycle hooks so memory is captured automatically. You do not have to call `memory_save` for routine work; the hooks observe tool use, prompts, and session boundaries and write observations for you. + +## Quick start + +Install the plugin and the hooks register themselves: + +```bash +/plugin marketplace add rohitg00/agentmemory +/plugin install agentmemory +``` + +Watch observations land live at `http://localhost:3113`. + +## What the hooks do + +- Session start and end frame each unit of work and let `handoff` resume it. +- Tool-use hooks capture what changed and why, the raw material for `recall` and `recap`. +- Prompt-submit captures intent. Pre-compact preserves context before the host trims it. +- A post-commit hook links commits to sessions, which powers `commit-context` and `commit-history`. + +## Important + +- Capture is on by default and is zero-LLM. Turning observations into LLM summaries (`AGENTMEMORY_AUTO_COMPRESS`) and injecting them back into context (`AGENTMEMORY_INJECT_CONTEXT`) are separate opt-ins because they spend tokens. +- If observations are missing, confirm the plugin is enabled and the server is running. See ../_shared/TROUBLESHOOTING.md. + +## See also + +- agentmemory-config for the capture and injection flags. +- The handoff, recap, and session-history skills consume what these hooks record. + +## Reference + +The exact registered hook events live in REFERENCE.md, generated from `plugin/hooks/hooks.json`. diff --git a/plugin/skills/agentmemory-mcp-tools/REFERENCE.md b/plugin/skills/agentmemory-mcp-tools/REFERENCE.md new file mode 100644 index 0000000..0c556fc --- /dev/null +++ b/plugin/skills/agentmemory-mcp-tools/REFERENCE.md @@ -0,0 +1,65 @@ +# agentmemory MCP tools reference + +Generated from `src/mcp/tools-registry.ts`. Do not edit the block below by hand; run `npm run skills:gen` after changing the registry. + + +agentmemory exposes 53 MCP tools. 8 are in the lean core set (`--tools core` or `AGENTMEMORY_TOOLS=core`); the rest load with `--tools all` (default). + +| Tool | Core | Parameters | Purpose | +| --- | --- | --- | --- | +| `memory_action_create` | | `title`*: string, `description`: string, `priority`: number, `project`: string, `tags`: string, `parentId`: string, `requires`: string | Create an actionable work item with typed dependencies. Actions track what agents need to do and how work items relate to each other. | +| `memory_action_update` | | `actionId`*: string, `status`: string, `result`: string, `priority`: number | Update an action's status, priority, or details. Set status to 'done' to complete it and unblock dependent actions. | +| `memory_audit` | | `operation`: string, `limit`: number | View the audit trail of memory operations. | +| `memory_checkpoint` | | `operation`*: string, `name`: string, `checkpointId`: string, `status`: string, `type`: string, `linkedActionIds`: string | Create or resolve an external checkpoint (CI result, approval, deploy status) that gates action progress. | +| `memory_claude_bridge_sync` | | `direction`*: string | Sync memory state to/from Claude Code's native MEMORY.md file. | +| `memory_commit_lookup` | | `sha`*: string | Look up the agent session(s) that produced a specific git commit, given its SHA. Returns the commit metadata and linked sessions. | +| `memory_commits` | | `branch`: string, `repo`: string, `limit`: number | List recent commits linked to agent sessions, optionally filtered by branch or repo. | +| `memory_compress_file` | | `filePath`*: string | Compress a markdown file to reduce token usage while preserving headings, URLs, and code blocks. Creates a .original.md backup before writing. | +| `memory_consolidate` | yes | `tier`: string | Run the 4-tier memory consolidation pipeline (working -> episodic -> semantic -> procedural). | +| `memory_crystallize` | | `actionIds`*: string, `project`: string, `sessionId`: string | Compress completed action chains into compact crystal digests using LLM summarization. Extracts narrative, key outcomes, files affected, and lessons. | +| `memory_diagnose` | yes | `categories`: string | Run health checks across all subsystems (actions, leases, sentinels, sketches, signals, sessions, memories, mesh). Identifies stuck, orphaned, and inconsistent state. | +| `memory_export` | | none | Export all memory data as JSON. | +| `memory_facet_query` | | `matchAll`: string, `matchAny`: string, `targetType`: string | Query targets by facet tags with AND/OR logic. Find all actions tagged priority:urgent AND team:backend. | +| `memory_facet_tag` | | `targetId`*: string, `targetType`*: string, `dimension`*: string, `value`*: string | Attach a structured tag (dimension:value) to an action, memory, or observation for multi-dimensional categorization. | +| `memory_file_history` | | `files`*: string, `sessionId`: string | Get past observations about specific files. | +| `memory_frontier` | | `project`: string, `agentId`: string, `limit`: number | Get all unblocked actions ranked by priority and urgency. Returns the frontier of actionable work with no unsatisfied dependencies. | +| `memory_governance_delete` | | `memoryIds`*: string, `reason`: string | Delete specific memories with audit trail. | +| `memory_graph_query` | | `startNodeId`: string, `nodeType`: string, `maxDepth`: number, `query`: string | Query the knowledge graph for entities and relationships. | +| `memory_heal` | | `categories`: string, `dryRun`: string | Auto-fix all fixable issues found by diagnostics. Unblocks stuck actions, expires stale leases, cleans up orphaned data. | +| `memory_insight_list` | | `project`: string, `minConfidence`: number, `limit`: number | List synthesized insights, higher-order observations derived from patterns across memories, lessons, and crystals. | +| `memory_lease` | | `actionId`*: string, `agentId`*: string, `operation`*: string, `result`: string, `ttlMs`: number | Acquire, release, or renew an exclusive lease on an action. Prevents multiple agents from working on the same thing. | +| `memory_lesson_recall` | | `query`*: string, `project`: string, `minConfidence`: number, `limit`: number | Search lessons by query. Returns lessons sorted by confidence and recency. Use to check what the agent has learned before making decisions. | +| `memory_lesson_save` | yes | `content`*: string, `context`: string, `confidence`: number, `project`: string, `tags`: string | Save a lesson learned from this session. Lessons have confidence scores that strengthen when reinforced and decay when not used. Duplicate content auto-strengthens the existing lesson. | +| `memory_mesh_sync` | | `peerId`: string, `direction`: string | Sync memories and actions with peer agentmemory instances for multi-agent collaboration. | +| `memory_next` | | `project`: string, `agentId`: string | Get the single most important next action to work on. Combines dependency resolution, priority, and recency into a score. | +| `memory_obsidian_export` | | `vaultDir`: string, `types`: string | Export memories, lessons, and crystals as Obsidian-compatible Markdown files with YAML frontmatter and wikilinks for graph view. | +| `memory_patterns` | | `project`: string | Detect recurring patterns across sessions. | +| `memory_profile` | | `project`*: string, `refresh`: string | User/project profile with top concepts and file patterns. | +| `memory_recall` | yes | `query`*: string, `limit`: number, `format`: string, `token_budget`: number | Search past session observations for relevant context. Use when you need to recall what happened in previous sessions, find past decisions, or look up how a file was modified before. | +| `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_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. | +| `memory_signal_read` | | `agentId`*: string, `unreadOnly`: string, `threadId`: string, `limit`: number | Read messages for an agent. Marks delivered messages as read. | +| `memory_signal_send` | | `from`*: string, `to`: string, `content`*: string, `type`: string, `replyTo`: string | Send a message to another agent or broadcast. Supports threading, typed messages, and TTL expiration. | +| `memory_sketch_create` | | `title`*: string, `description`: string, `expiresInMs`: number, `project`: string | Create an ephemeral action graph for exploratory work. Auto-expires after TTL. Can be promoted to permanent actions or discarded. | +| `memory_sketch_promote` | | `sketchId`*: string, `project`: string | Promote a sketch's ephemeral actions to permanent actions. Makes the exploratory work official. | +| `memory_slot_append` | | `label`*: string, `text`*: string | Append text to an existing slot. Fails with 413 if the append would exceed the slot's sizeLimit, agent must compact via memory_slot_replace first. | +| `memory_slot_create` | | `label`*: string, `content`: string, `sizeLimit`: number, `description`: string, `pinned`: string, `scope`: string | Create a new slot. Reject if a slot with the same label already exists. | +| `memory_slot_delete` | | `label`*: string | Delete a slot. Seeded default slots can be deleted unless marked readOnly. | +| `memory_slot_get` | | `label`*: string | Read a single slot by label. | +| `memory_slot_list` | | none | List all memory slots (pinned + project + global). Slots are editable, size-limited memory units the agent can read and modify across sessions. | +| `memory_slot_replace` | | `label`*: string, `content`*: string | Replace slot content in place. Fails if content exceeds sizeLimit. | +| `memory_smart_search` | yes | `query`*: string, `expandIds`: string, `limit`: number | Hybrid semantic+keyword search with progressive disclosure. | +| `memory_snapshot_create` | | `message`: string | Create a git-versioned snapshot of current memory state. | +| `memory_team_feed` | | `limit`: number | Get recent shared items from all team members. | +| `memory_team_share` | | `itemId`*: string, `itemType`*: string | Share a memory or observation with team members. | +| `memory_timeline` | | `anchor`*: string, `project`: string, `before`: number, `after`: number | Chronological observations around an anchor point. | +| `memory_verify` | | `id`*: string | Verify a memory or observation by tracing its citation chain back to source observations and session context. Returns provenance information including confidence scores. | +| `memory_vision_search` | | `queryText`: string, `queryImageRef`: string, `queryImageBase64`: string, `topK`: number, `sessionId`: string | Cross-modal image search via CLIP embeddings. Pass queryText to find screenshots matching a description, or queryImageBase64/queryImageRef to find similar images. Requires AGENTMEMORY_IMAGE_EMBEDDINGS=true. | + +`*` marks required parameters. + diff --git a/plugin/skills/agentmemory-mcp-tools/SKILL.md b/plugin/skills/agentmemory-mcp-tools/SKILL.md new file mode 100644 index 0000000..00445ef --- /dev/null +++ b/plugin/skills/agentmemory-mcp-tools/SKILL.md @@ -0,0 +1,39 @@ +--- +name: agentmemory-mcp-tools +description: Map of every agentmemory MCP tool, what each does, and its parameters. Use when choosing which memory tool to call, when a tool name or argument is unclear, or when answering what agentmemory can do via MCP. +user-invocable: false +--- + +agentmemory exposes its full capability set as MCP tools. This skill is the index: it tells you which tool to reach for and where to find exact parameters. + +## Quick start + +Save then recall: + +1. `memory_save` with `content` (the insight), `concepts` (comma-separated keywords), `files` (comma-separated paths). +2. `memory_smart_search` with `query` and `limit` to retrieve it later. This runs hybrid BM25 plus vector plus graph-expanded search. + +## Tool families + +- Capture: `memory_save`, `memory_observe` flows, `memory_compress_file`. +- Retrieve: `memory_smart_search`, `memory_recall`, `memory_file_history`, `memory_timeline`, `memory_vision_search`. +- Sessions and commits: `memory_sessions`, `memory_commits`, `memory_commit_lookup`. +- Knowledge and graph: `memory_lesson_save`, `memory_lesson_recall`, `memory_graph_query`, `memory_relations`, `memory_patterns`, `memory_crystallize`. +- Structured slots: `memory_slot_create`, `memory_slot_append`, `memory_slot_get`, `memory_slot_list`, `memory_slot_replace`, `memory_slot_delete`. +- Governance and health: `memory_governance_delete`, `memory_audit`, `memory_verify`, `memory_heal`, `memory_diagnose`. + +## Workflow + +1. Pick the narrowest tool for the task. Prefer `memory_smart_search` for open recall, `memory_recall` when you already have a focused query, `memory_sessions` for session listings. +2. Look up exact parameter names and which are required in REFERENCE.md before calling. +3. Pass only documented fields. REST handlers whitelist fields and drop unknown ones. + +## See also + +- agentmemory-rest-api for the HTTP equivalents. +- agentmemory-config for tool-visibility and feature flags. +- The user-invocable action skills (remember, recall, recap, handoff, forget) wrap the most common tools. + +## Reference + +Full tool table with parameters and the core-set marking lives in REFERENCE.md, generated from source so it never drifts. diff --git a/plugin/skills/agentmemory-rest-api/REFERENCE.md b/plugin/skills/agentmemory-rest-api/REFERENCE.md new file mode 100644 index 0000000..d36171d --- /dev/null +++ b/plugin/skills/agentmemory-rest-api/REFERENCE.md @@ -0,0 +1,129 @@ +# agentmemory REST API reference + +Generated from `src/triggers/api.ts`. Do not edit the block below by hand; run `npm run skills:gen` after changing the registered endpoints. + + +The REST API is the primary surface. All paths are under `http://localhost:3111` (override with `--port`). When `AGENTMEMORY_SECRET` is set, send `Authorization: Bearer $AGENTMEMORY_SECRET`; localhost is otherwise open. + +117 registered endpoints: + +| Method | Path | +| --- | --- | +| POST | `/agentmemory/actions` | +| POST | `/agentmemory/actions/edges` | +| GET | `/agentmemory/actions/get` | +| POST | `/agentmemory/actions/update` | +| GET | `/agentmemory/audit` | +| POST | `/agentmemory/auto-forget` | +| GET | `/agentmemory/branch/detect` | +| GET | `/agentmemory/branch/sessions` | +| GET | `/agentmemory/branch/worktrees` | +| POST | `/agentmemory/cascade-update` | +| POST | `/agentmemory/checkpoints` | +| POST | `/agentmemory/checkpoints/resolve` | +| GET | `/agentmemory/claude-bridge/read` | +| POST | `/agentmemory/claude-bridge/sync` | +| GET | `/agentmemory/commits` | +| POST | `/agentmemory/compress-file` | +| GET | `/agentmemory/config/flags` | +| POST | `/agentmemory/consolidate` | +| POST | `/agentmemory/consolidate-pipeline` | +| POST | `/agentmemory/context` | +| GET | `/agentmemory/crystals` | +| POST | `/agentmemory/crystals/auto` | +| POST | `/agentmemory/crystals/create` | +| POST | `/agentmemory/diagnostics` | +| GET | `/agentmemory/diagnostics/followup` | +| POST | `/agentmemory/diagnostics/heal` | +| POST | `/agentmemory/enrich` | +| POST | `/agentmemory/evict` | +| POST | `/agentmemory/evolve` | +| GET | `/agentmemory/export` | +| POST | `/agentmemory/facets` | +| POST | `/agentmemory/facets/query` | +| POST | `/agentmemory/facets/remove` | +| GET | `/agentmemory/facets/stats` | +| POST | `/agentmemory/file-context` | +| POST | `/agentmemory/flow/compress` | +| POST | `/agentmemory/forget` | +| GET | `/agentmemory/frontier` | +| POST | `/agentmemory/generate-rules` | +| POST | `/agentmemory/governance/bulk-delete` | +| DELETE | `/agentmemory/governance/memories` | +| POST | `/agentmemory/graph/build` | +| POST | `/agentmemory/graph/extract` | +| POST | `/agentmemory/graph/query` | +| POST | `/agentmemory/graph/reset` | +| POST | `/agentmemory/graph/snapshot-rebuild` | +| GET | `/agentmemory/graph/stats` | +| GET | `/agentmemory/health` | +| POST | `/agentmemory/import` | +| GET | `/agentmemory/insights` | +| POST | `/agentmemory/insights/search` | +| POST | `/agentmemory/leases/acquire` | +| POST | `/agentmemory/leases/release` | +| POST | `/agentmemory/leases/renew` | +| POST | `/agentmemory/lessons` | +| POST | `/agentmemory/lessons/search` | +| POST | `/agentmemory/lessons/strengthen` | +| GET | `/agentmemory/livez` | +| GET | `/agentmemory/memories` | +| GET | `/agentmemory/memories/:id` | +| GET | `/agentmemory/mesh/export` | +| POST | `/agentmemory/mesh/peers` | +| POST | `/agentmemory/mesh/receive` | +| POST | `/agentmemory/mesh/sync` | +| POST | `/agentmemory/migrate` | +| GET | `/agentmemory/next` | +| GET | `/agentmemory/observations` | +| POST | `/agentmemory/observe` | +| POST | `/agentmemory/obsidian/export` | +| POST | `/agentmemory/patterns` | +| GET | `/agentmemory/procedural` | +| GET | `/agentmemory/profile` | +| POST | `/agentmemory/reflect` | +| POST | `/agentmemory/relations` | +| POST | `/agentmemory/remember` | +| POST | `/agentmemory/replay/import-jsonl` | +| GET | `/agentmemory/replay/load` | +| GET | `/agentmemory/replay/sessions` | +| POST | `/agentmemory/routines` | +| POST | `/agentmemory/routines/run` | +| GET | `/agentmemory/routines/status` | +| POST | `/agentmemory/search` | +| GET | `/agentmemory/semantic` | +| POST | `/agentmemory/sentinels` | +| POST | `/agentmemory/sentinels/cancel` | +| POST | `/agentmemory/sentinels/check` | +| POST | `/agentmemory/sentinels/trigger` | +| GET | `/agentmemory/session/by-commit` | +| POST | `/agentmemory/session/commit` | +| POST | `/agentmemory/session/end` | +| POST | `/agentmemory/session/start` | +| GET | `/agentmemory/sessions` | +| GET | `/agentmemory/signals` | +| POST | `/agentmemory/signals/send` | +| POST | `/agentmemory/sketches` | +| POST | `/agentmemory/sketches/add` | +| POST | `/agentmemory/sketches/discard` | +| POST | `/agentmemory/sketches/gc` | +| POST | `/agentmemory/sketches/promote` | +| GET | `/agentmemory/slot` | +| POST | `/agentmemory/slot/append` | +| POST | `/agentmemory/slot/reflect` | +| POST | `/agentmemory/slot/replace` | +| GET | `/agentmemory/slots` | +| POST | `/agentmemory/smart-search` | +| POST | `/agentmemory/snapshot/create` | +| POST | `/agentmemory/snapshot/restore` | +| GET | `/agentmemory/snapshots` | +| POST | `/agentmemory/summarize` | +| GET | `/agentmemory/team/feed` | +| GET | `/agentmemory/team/profile` | +| POST | `/agentmemory/team/share` | +| POST | `/agentmemory/timeline` | +| POST | `/agentmemory/verify` | +| GET | `/agentmemory/viewer` | +| POST | `/agentmemory/vision-embed` | +| POST | `/agentmemory/vision-search` | + diff --git a/plugin/skills/agentmemory-rest-api/SKILL.md b/plugin/skills/agentmemory-rest-api/SKILL.md new file mode 100644 index 0000000..898e94a --- /dev/null +++ b/plugin/skills/agentmemory-rest-api/SKILL.md @@ -0,0 +1,43 @@ +--- +name: agentmemory-rest-api +description: The agentmemory HTTP REST API surface, the primary protocol for talking to the memory server. Use when calling agentmemory over HTTP, when MCP is unavailable and you need a fallback, or when integrating a host that does not speak MCP. +user-invocable: false +--- + +REST is agentmemory's primary surface. MCP is a bridge on top of it. Every memory operation has an HTTP endpoint under `http://localhost:3111/agentmemory/*`. + +## Quick start + +```bash +# liveness +curl -fsS http://localhost:3111/agentmemory/livez + +# save +curl -X POST http://localhost:3111/agentmemory/remember \ + -H "Content-Type: application/json" \ + -d '{"content":"chose JWT refresh rotation","concepts":["jwt-refresh-rotation"]}' + +# recall +curl -X POST http://localhost:3111/agentmemory/smart-search \ + -H "Content-Type: application/json" \ + -d '{"query":"auth token strategy","limit":5}' +``` + +## Auth + +By default localhost is open and no auth is needed. When `AGENTMEMORY_SECRET` is set, every request needs `Authorization: Bearer $AGENTMEMORY_SECRET`. See agentmemory-config. + +## Conventions + +- Save returns `201`, reads return `200`, validation errors return `400`. +- Handlers whitelist body fields and drop unknown ones, so passing extra keys is safe but ignored. +- The port is configurable with `--port` or `--instance`; streams, viewer, and engine derive from it. + +## See also + +- agentmemory-mcp-tools for the MCP equivalents. +- agentmemory-config for the port quartet and the secret. + +## Reference + +The full endpoint list with methods lives in REFERENCE.md, generated from `src/triggers/api.ts`. diff --git a/plugin/skills/commit-context/EXAMPLES.md b/plugin/skills/commit-context/EXAMPLES.md new file mode 100644 index 0000000..a0fe52c --- /dev/null +++ b/plugin/skills/commit-context/EXAMPLES.md @@ -0,0 +1,71 @@ +# commit-context worked examples + +## 1. Line range with a linked session + +User: "Why is this retry loop here?" (pointing at lines 40-52 of refresh.ts) + +Find the SHA: + +```bash +git blame -L 40,52 src/auth/refresh.ts +# 9a1b2c3d (dev 2026-06-07) ... retry on revoked token +``` + +Look it up: + +```json +memory_commit_lookup { "sha": "9a1b2c3d4e5f60718293a4b5c6d7e8f901234567" } +``` + +Response: + +```json +{ + "commit": { "sha": "9a1b2c3d...", "short": "9a1b2c3", "branch": "main", + "author": "dev", "message": "rotate refresh tokens" }, + "sessions": [ { "id": "7f3a9c21", "project": "app", "observationCount": 14, + "summary": "Reworked refresh rotation" } ] +} +``` + +Present: + +> `9a1b2c3` on `main` by dev: "rotate refresh tokens". Linked to session +> `7f3a9c2` "Auth refresh rework" (14 obs). The retry loop handles a token +> revoked mid-flight, per the session's high-importance observations. + +## 2. Function lookup, commit predates linking + +User: "What was the agent doing when it wrote validateScope?" + +```bash +git log -L :validateScope:src/auth/scope.ts -n 1 +# 1122aabb ... +``` + +```json +memory_commit_lookup { "sha": "1122aabbccddeeff00112233445566778899aabb" } +``` + +Response: + +```json +{ "commit": null } +``` + +Present: + +> `1122aab` predates agent session linking, so there is no recorded session. +> From `git show`: it added `validateScope` to enforce per-token scopes. I can +> show the full diff if useful. + +## 3. Bare path + +User: "Give me context on src/middleware/limit.ts." + +```bash +git log -n 1 -- src/middleware/limit.ts +``` + +Take the SHA, run `memory_commit_lookup`, present commit plus linked session in +the same shape as example 1. diff --git a/plugin/skills/commit-context/SKILL.md b/plugin/skills/commit-context/SKILL.md new file mode 100644 index 0000000..fa782ef --- /dev/null +++ b/plugin/skills/commit-context/SKILL.md @@ -0,0 +1,64 @@ +--- +name: commit-context +description: Trace a file, function, or line back to the agent session that produced its current commit. Use when the user asks "why is this code here", "what was the agent doing when this changed", "who wrote this", or wants context on a specific location in the codebase. +argument-hint: "[file, function, or line]" +user-invocable: true +--- + +The user wants commit context for: $ARGUMENTS + +## Quick start + +```bash +git blame -L 40,52 src/auth/refresh.ts # -> SHA 9a1b2c3d +``` + +```json +memory_commit_lookup { "sha": "9a1b2c3d4e5f60718293a4b5c6d7e8f901234567" } +``` + +Expected output: + +```text +9a1b2c3 on main by dev: "rotate refresh tokens" +Linked session 7f3a9c2 "Auth refresh rework", 14 obs. +``` + +## Why + +Report only what git and the lookup return. When the lookup gives `commit: null`, +the commit predates session linking; do not invent intent. + +## Workflow + +1. Find the SHA: `git blame -L , ` for a line range; + `git log -L ::` for a function; `git log -n 1 -- ` for a + bare path. +2. Look it up: `memory_commit_lookup { "sha": "" }`. +3. Present the commit (sha, short sha, branch, author, message), the linked + session(s) (id, project, started/ended, observation count, summary), and the + importance >= 7 observations via `memory_recall` when available. + +## Anti-patterns + +WRONG: lookup returns `{ "commit": null }`, you narrate "the agent was +refactoring auth" from the diff alone. + +RIGHT: "This commit predates session linking, so there is no recorded agent +session. From `git show`: it changed token rotation in refresh.ts." + +## Checklist + +- SHA came from git blame/log, not a guess. +- `commit: null` reported as "predates linking", no fabricated session. +- Session details quote the lookup response verbatim. +- No intent claimed beyond what observations state. + +## See also + +- `commit-history`: list many agent-linked commits at once. +- `recall`: dig deeper into the linked session's observations. + +## Troubleshooting + +See ../_shared/TROUBLESHOOTING.md if `memory_commit_lookup` is not available. diff --git a/plugin/skills/commit-history/EXAMPLES.md b/plugin/skills/commit-history/EXAMPLES.md new file mode 100644 index 0000000..d04a6bb --- /dev/null +++ b/plugin/skills/commit-history/EXAMPLES.md @@ -0,0 +1,71 @@ +# commit-history worked examples + +## 1. Branch filter + +User: "Show agent commits on main." + +Invocation: + +```json +memory_commits { "branch": "main", "limit": 100 } +``` + +Response: + +```json +{ + "commits": [ + { "short": "9a1b2c3", "branch": "main", "authoredAt": "2026-06-07T09:12:00Z", + "message": "rotate refresh tokens", "sessionIds": ["7f3a9c21"], + "observationCount": 14, "files": 3 }, + { "short": "b21d004", "branch": "main", "authoredAt": "2026-06-05T14:40:00Z", + "message": "rate limiter audit", "sessionIds": ["b21d004e"], + "observationCount": 9, "files": 1 } + ] +} +``` + +Present: + +> - `9a1b2c3` main 2026-06-07 "rotate refresh tokens", session `7f3a9c2` (14 obs, 3 files) +> - `b21d004` main 2026-06-05 "rate limiter audit", session `b21d004` (9 obs, 1 file) + +## 2. Bare number as limit + +User: "commit-history 5" + +Treat `5` as the limit: + +```json +memory_commits { "limit": 5 } +``` + +Render the five newest linked commits in the same format. + +## 3. Empty result + +User: "Show agent commits on release-2.0." + +```json +memory_commits { "branch": "release-2.0", "limit": 100 } +``` + +Response: + +```json +{ "commits": [] } +``` + +Present: + +> No agent-linked commits on `release-2.0`. Drop the branch filter to see all +> linked commits, or try a different branch. + +REST fallback for this same call, with encoding: + +```http +GET /agentmemory/commits?branch=release-2.0&limit=100 +``` + +Build it with `URLSearchParams` so a branch like `feat/a&b` becomes +`feat%2Fa%26b` rather than breaking the query. diff --git a/plugin/skills/commit-history/SKILL.md b/plugin/skills/commit-history/SKILL.md new file mode 100644 index 0000000..b05c003 --- /dev/null +++ b/plugin/skills/commit-history/SKILL.md @@ -0,0 +1,62 @@ +--- +name: commit-history +description: List recent git commits linked to agent sessions, optionally filtered by branch or repo. Use when the user asks "show agent commits", "what has the agent shipped", "list linked commits", or wants commits with their session context. +argument-hint: "[branch=... repo=... limit=...]" +user-invocable: true +--- + +The user wants a list of agent-linked commits. Filter args: $ARGUMENTS + +## Quick start + +```json +memory_commits { "branch": "main", "limit": 20 } +``` + +Expected output: + +```text +9a1b2c3 main 2026-06-07 "rotate refresh tokens" · session 7f3a9c2 (14 obs) +b21d004 main 2026-06-05 "rate limiter audit" · session b21d004 (9 obs) +``` + +## Why + +Render only the commits the tool returned, newest first. An empty result means +the filter matched nothing, not that work is missing. + +## Workflow + +1. Parse `$ARGUMENTS` for `branch=`, `repo=`, + `limit=`. A bare numeric token is the limit. Defaults: no branch, no repo, + limit 100, max 500. +2. Call `memory_commits` with the parsed filters. +3. Render reverse-chronologically: short sha, branch, authored timestamp, first + line of the message, linked session id(s) (first 8) with observation counts, + and file count when `files` is present. +4. Empty result: tell the user the filter matched nothing and suggest dropping + the branch or repo filter. + +## Anti-patterns + +WRONG (REST fallback): concatenate `?branch=` + raw branch name, so a name with +`?`, `&`, or `#` corrupts the query string. + +RIGHT: URL-encode every value with `URLSearchParams`/`encodeURIComponent` before +appending to `GET /agentmemory/commits`. + +## Checklist + +- Filters parsed; bare number treated as limit; limit capped at 500. +- Output is reverse-chronological. +- Session ids and observation counts come straight from the response. +- REST fallback URL-encodes branch, repo, and limit. + +## See also + +- `commit-context`: drill into one commit's session. +- `recall`: search the observations behind a linked session. + +## Troubleshooting + +See ../_shared/TROUBLESHOOTING.md if `memory_commits` is not available. diff --git a/plugin/skills/forget/EXAMPLES.md b/plugin/skills/forget/EXAMPLES.md new file mode 100644 index 0000000..c391bda --- /dev/null +++ b/plugin/skills/forget/EXAMPLES.md @@ -0,0 +1,75 @@ +# forget worked examples + +## 1. Delete a leaked secret + +User: "Forget that note where I pasted the API key." + +Search: + +```json +memory_smart_search { "query": "api key", "limit": 20 } +``` + +Response: + +```json +{ + "results": [ + { "id": "abc12345", "sessionId": "7f3a9c21", "title": "Pasted staging API key", + "narrative": "STAGING_KEY=sk-live-..." } + ] +} +``` + +Present and gate: + +> Found 1 match: +> - `abc12345` (session `7f3a9c2`) "Pasted staging API key" +> +> Delete this memory? (yes/no) + +After the user says yes: + +```json +memory_governance_delete { "memoryIds": ["abc12345"], "reason": "user privacy request" } +``` + +Response: + +```json +{ "deleted": 1 } +``` + +Present: + +> Deleted 1 memory (`abc12345`). + +## 2. Drop a whole session's observations + +User: "Delete everything from the throwaway spike session." + +Search, identify the session, then collect every memory id in it: + +```json +memory_smart_search { "query": "spike prototype throwaway", "limit": 20 } +``` + +Response has 4 results all with `sessionId: c98f1100`. Show all four, get a yes, +then pass every id: + +```json +memory_governance_delete { + "memoryIds": ["m1", "m2", "m3", "m4"], + "reason": "drop throwaway spike session" +} +``` + +Never send `{ "sessionId": "c98f1100" }`; the MCP deletes by memory id only. + +## 3. User declines + +User: "Actually, on second thought, keep them." + +Do not call delete. Confirm: + +> Understood, nothing deleted. The 4 memories from session `c98f110` are intact. diff --git a/plugin/skills/forget/SKILL.md b/plugin/skills/forget/SKILL.md new file mode 100644 index 0000000..32ec539 --- /dev/null +++ b/plugin/skills/forget/SKILL.md @@ -0,0 +1,66 @@ +--- +name: forget +description: Delete specific observations from agentmemory after showing them and getting explicit confirmation. Use when the user says "forget this", "delete memory", "remove that note", or wants to scrub specific data for privacy. +argument-hint: "[what to forget - session ID, file path, or search term]" +user-invocable: true +--- + +The user wants to remove data from agentmemory: $ARGUMENTS + +## Quick start + +```json +memory_smart_search { "query": "old api key in config", "limit": 20 } +``` + +Show the matches, get a yes, then: + +```json +memory_governance_delete { "memoryIds": ["abc12345", "def67890"], "reason": "user privacy request" } +``` + +Expected output: + +```text +Found 2 matching memories. Confirmed. Deleted 2 memories. +``` + +## Why + +This is destructive and irreversible. Show exactly what will be deleted and get +an explicit yes before calling delete. Delete by memory ID, never a bare session. + +## Workflow + +1. Search with `memory_smart_search`, the user's text as `query`, `limit: 20`. +2. Show what matched: session ids, memory ids, titles. Ask for explicit + confirmation. Do not proceed on silence or a vague "sure, whatever". +3. On confirmation, call `memory_governance_delete` with `memoryIds` (array or + comma-separated string) and optional `reason` (default `plugin skill request`). +4. To drop a whole session, collect every memory id in that session from the + search results and pass them all. The MCP does not accept a bare `sessionId`. +5. Report the deletion count back. + +## Anti-patterns + +WRONG: search returns matches, you immediately call `memory_governance_delete` +without showing them or waiting for a yes. + +RIGHT: list the matches, ask "Delete these 2? (yes/no)", and only delete after +an explicit yes. + +## Checklist + +- Matches were shown to the user before any delete. +- An explicit yes was received, not assumed. +- `memoryIds` holds real ids from the search, never a bare `sessionId`. +- Final message states the actual count deleted. + +## See also + +- `remember`: the write side; forget is its undo. +- `recall`: find the exact memory id before deleting. + +## Troubleshooting + +See ../_shared/TROUBLESHOOTING.md if `memory_smart_search` or `memory_governance_delete` is not available. diff --git a/plugin/skills/handoff/EXAMPLES.md b/plugin/skills/handoff/EXAMPLES.md new file mode 100644 index 0000000..11e1d58 --- /dev/null +++ b/plugin/skills/handoff/EXAMPLES.md @@ -0,0 +1,56 @@ +# handoff worked examples + +## 1. Resume with an open question + +User: "Where were we?" + +Project cwd: `/Users/dev/app`. Invocation: + +```json +memory_sessions { "limit": 20 } +``` + +Response: + +```json +{ + "sessions": [ + { "id": "7f3a9c21", "cwd": "/Users/dev/app", "title": "Auth refresh rework", + "status": "completed", "startedAt": "2026-06-07T09:00:00Z", + "summary": "Reworked refresh rotation. Open: should logout revoke all device tokens?", + "concepts": ["jwt-refresh-rotation", "logout"] } + ] +} +``` + +Pull supporting context: + +```json +memory_recall { "query": "jwt-refresh-rotation logout", "limit": 10 } +``` + +Present: + +> Resuming `7f3a9c2` "Auth refresh rework". +> **Open question:** should logout revoke all device tokens or just the current one? +> Touched `auth/refresh.ts`, `auth/logout.ts`. Refresh rotation is done. +> **Next step:** decide the revoke scope, then finish `auth/logout.ts`. + +## 2. Sibling-repo guard + +Project cwd: `/Users/dev/repo-a`. Sessions include one with `cwd: +/Users/dev/repo-a-staging`. + +A raw prefix would match `repo-a-staging`. The boundary check rejects it because +`/Users/dev/repo-a-staging` does not equal `/Users/dev/repo-a` and does not start +with `/Users/dev/repo-a/`. Pick the real `repo-a` session instead, or fall back +to the most recent session overall if none matches. + +## 3. Empty session + +User: "Resume." + +If the matched session has zero observations: + +> The most recent session `c98f110` for this project has no recorded +> observations. Nothing to hand off. Want to start fresh from the current state? diff --git a/plugin/skills/handoff/SKILL.md b/plugin/skills/handoff/SKILL.md new file mode 100644 index 0000000..3b4e514 --- /dev/null +++ b/plugin/skills/handoff/SKILL.md @@ -0,0 +1,68 @@ +--- +name: handoff +description: Resume the most recent agent session for the current working directory, leading with any unanswered question. Use when the user says "where were we", "resume", "handoff", "pick up where I left off", or starts a session with no fresh context. +argument-hint: "[optional cwd override]" +user-invocable: true +--- + +The user wants to resume work. Optional cwd override: $ARGUMENTS + +## Quick start + +```json +memory_sessions { "limit": 20 } +``` + +Pick the most recent session whose `cwd` matches this project, then: +`memory_recall { "query": "", "limit": 10 }`. + +Expected output: + +```text +Resuming 7f3a9c2 "Auth refresh rework". +Open question: should logout revoke all device tokens or just the current one? +Next step: decide revoke scope, then update auth/logout.ts. +``` + +## Why + +Match the session by directory boundary, not raw prefix, so a sibling repo never +gets mistaken for this one. Never invent observations for an empty session. + +## Workflow + +1. Resolve the project path: if `$ARGUMENTS` is given, normalize it to absolute + (`path.resolve(process.cwd(), $ARGUMENTS)`); else use the cwd. +2. Call `memory_sessions`. Pick the most recent session whose normalized `cwd` + matches by directory boundary: equality, OR `cwd.startsWith(projectPath + sep)`, + OR `projectPath.startsWith(cwd + sep)`. Prefer `completed` over `abandoned`. + No match: fall back to the single most recent session overall. +3. If the session ended on an unanswered user-facing question, surface it FIRST. + Look in `summary` or recent `conversation` observations whose `narrative` + ends in `?`. +4. Summarize: title/summary, key files, key decisions or errors, using + `memory_recall` on the top concepts, limit 10. +5. End with one concrete "next step?" pointer. + +## Anti-patterns + +WRONG: `session.cwd.startsWith(projectPath)` matches `/repo-a-staging` when the +project is `/repo-a`, resuming the wrong repo's session. + +RIGHT: `session.cwd === projectPath || session.cwd.startsWith(projectPath + sep)`, +a directory-boundary check that cannot cross sibling repos. + +## Checklist + +- cwd override resolved to an absolute, normalized path. +- Match used a directory-boundary check, not a raw prefix. +- Unanswered question (if any) leads the response. +- Empty session is reported plainly, with an offer to start fresh. + +## See also + +- `recap`, `session-history`, `recall`: same session data, broader views. + +## Troubleshooting + +See ../_shared/TROUBLESHOOTING.md if `memory_sessions` or `memory_recall` is not available. diff --git a/plugin/skills/recall/EXAMPLES.md b/plugin/skills/recall/EXAMPLES.md new file mode 100644 index 0000000..8e58c56 --- /dev/null +++ b/plugin/skills/recall/EXAMPLES.md @@ -0,0 +1,74 @@ +# recall worked examples + +## 1. Hit on a past decision + +User: "Did we ever decide how to cache sessions?" + +Invocation: + +```json +memory_smart_search { "query": "session caching decision", "limit": 10 } +``` + +Response: + +```json +{ + "results": [ + { "sessionId": "7f3a9c21", "type": "decision", "title": "Drop Redis for iii state store", + "narrative": "We decided to drop Redis and use the iii-engine state store for session caching.", + "importance": 8 } + ] +} +``` + +Present: + +> Yes. In session `7f3a9c21` you decided to drop Redis and use the iii-engine +> state store for session caching (importance 8). + +## 2. Multiple results across sessions + +User: "Recall anything about the rate limiter." + +Invocation: + +```json +memory_smart_search { "query": "rate limiter", "limit": 10 } +``` + +Response: + +```json +{ + "results": [ + { "sessionId": "b21d004e", "type": "code", "title": "limit.ts counts per-IP", + "narrative": "Rate limiter counts per-IP, not per-user.", "importance": 6 }, + { "sessionId": "c98f1100", "type": "conversation", "title": "Discussed sliding window", + "narrative": "Considered a sliding-window limiter, deferred.", "importance": 4 } + ] +} +``` + +Present them grouped by session, leading with the per-IP code note. + +## 3. No results + +User: "Recall the GraphQL schema migration." + +Invocation: + +```json +memory_smart_search { "query": "graphql schema migration", "limit": 10 } +``` + +Response: + +```json +{ "results": [] } +``` + +Present: + +> No memories matched that. Try `schema change`, `graphql`, or `migration` as +> narrower terms, or `recap this week` to scan recent sessions. diff --git a/plugin/skills/recall/SKILL.md b/plugin/skills/recall/SKILL.md new file mode 100644 index 0000000..00f71fc --- /dev/null +++ b/plugin/skills/recall/SKILL.md @@ -0,0 +1,60 @@ +--- +name: recall +description: Search agentmemory for past observations, sessions, and learnings about a topic using hybrid BM25 plus vector plus graph search. Use when the user says "recall", "what did we do about", "did we ever", "have we seen", or needs context from past sessions. +argument-hint: "[search query]" +user-invocable: true +--- + +The user wants to recall past context about: $ARGUMENTS + +## Quick start + +```json +memory_smart_search { "query": "jwt refresh token rotation", "limit": 10 } +``` + +Expected output: + +```text +2 results across 2 sessions. +[importance 8] decision · "Rotate refresh tokens on every use" (session 7f3a9c21) +[importance 5] code · "limit.ts counts per-IP" (session b21d004e) +``` + +## Why + +Only surface what the tool returned. Never fabricate an observation, a session +id, or an importance score. If nothing comes back, say so. + +## Workflow + +1. Call `memory_smart_search` with the user's text as `query` and `limit: 10`. + Pass `project` when the user scopes to a specific repo. +2. Group results by session. +3. For each observation show its type, title, and narrative. +4. Lead with the high-signal observations (importance >= 7). +5. If zero results, suggest 2-3 alternative search terms and stop. Do not guess. + +## Anti-patterns + +WRONG: results are empty, so you write "We probably discussed token expiry last +week" from assumption. + +RIGHT: "No memories matched that query. Try `refresh token`, `session expiry`, +or `auth rotation`." + +## Checklist + +- Every observation shown came from the tool response. +- Results grouped by session, high-importance first. +- Empty results trigger alternative-term suggestions, not invention. +- No session id or score was paraphrased or rounded. + +## See also + +- `remember`: the write side; recall retrieves what it stores. +- `recap`, `handoff`, `session-history`: session-scoped views of the same data. + +## Troubleshooting + +See ../_shared/TROUBLESHOOTING.md if `memory_smart_search` is not available. diff --git a/plugin/skills/recap/EXAMPLES.md b/plugin/skills/recap/EXAMPLES.md new file mode 100644 index 0000000..4426c8c --- /dev/null +++ b/plugin/skills/recap/EXAMPLES.md @@ -0,0 +1,60 @@ +# recap worked examples + +## 1. This week + +User: "Recap this week." + +Window: last 7 days. Invocation: + +```json +memory_sessions { "limit": 30 } +``` + +Response (after filtering to cwd and the 7-day window): + +```json +{ + "sessions": [ + { "id": "7f3a9c21", "cwd": "/Users/dev/app", "title": "Auth refresh rework", + "startedAt": "2026-06-07T09:00:00Z", "observationCount": 14, "status": "completed", + "concepts": ["jwt-refresh-rotation", "auth-flow"] }, + { "id": "b21d004e", "cwd": "/Users/dev/app", "title": "Rate limiter audit", + "startedAt": "2026-06-05T14:00:00Z", "observationCount": 9, "status": "completed", + "concepts": ["rate-limiter", "per-ip-bug"] } + ] +} +``` + +Per session, pull highlights: + +```json +memory_recall { "query": "jwt-refresh-rotation auth-flow", "limit": 3 } +``` + +Present: + +> **2026-06-07** +> - `7f3a9c2` Auth refresh rework, 14 obs, completed +> - [8] Rotate refresh tokens on every use +> +> **2026-06-05** +> - `b21d004e` Rate limiter audit, 9 obs, completed +> - [7] limit.ts counts per-IP, not per-user +> +> 2 sessions across 2 days, 23 observations. + +## 2. Bare number + +User: "recap 3" + +Treat as `last 3`. Call `memory_sessions { "limit": 3 }`, group by date, same format. + +## 3. Empty window + +User: "Recap today." + +If `memory_sessions` returns no session whose `startedAt` is today and whose +`cwd` matches: + +> No sessions today for this project. The most recent was yesterday, `7f3a9c2` +> Auth refresh rework. Want a recap of that instead? diff --git a/plugin/skills/recap/SKILL.md b/plugin/skills/recap/SKILL.md new file mode 100644 index 0000000..b3ea5e9 --- /dev/null +++ b/plugin/skills/recap/SKILL.md @@ -0,0 +1,63 @@ +--- +name: recap +description: Summarize the last N agent sessions for the current project, grouped by date, with highlight observations per session. Use when the user asks "recap", "what have we been doing", "today", "this week", or wants a rollup of recent work. +argument-hint: "[last N | today | this week]" +user-invocable: true +--- + +The user wants a recap. Time window args: $ARGUMENTS + +## Quick start + +```json +memory_sessions { "limit": 30 } +``` + +Then per surviving session: `memory_recall { "query": "", "limit": 3 }`. + +Expected output: + +```text +2026-06-07 + 7f3a9c2 · "Auth refresh rework" · 14 obs · completed + - [8] Rotate refresh tokens on every use +3 sessions across 2 days, 41 observations. +``` + +## Why + +Only summarize sessions and observations the tools returned. An empty window is +a real answer, not a prompt to invent activity. + +## Workflow + +1. Parse `$ARGUMENTS`: `today` = current local date; `this week` = last 7 days; + `last ` or bare numeric = most recent N; empty = `last 10`. +2. Call `memory_sessions`, filter to the current project (match `cwd` against the + working directory), apply the window, sort by `startedAt` descending. +3. Group survivors by local calendar date (YYYY-MM-DD). +4. Per session list id (first 8), title or first prompt, observation count, + status. Indent 2-3 highlights (importance >= 7) from `memory_recall`. +5. End with "N sessions across M days, K observations." + +## Anti-patterns + +WRONG: window is empty, so you summarize "a productive week of auth work" from +memory of the conversation. + +RIGHT: "No sessions in the last 7 days for this project." + +## Checklist + +- Window parsed correctly from the argument. +- Sessions filtered to the current project's cwd. +- Highlights come from `memory_recall`, not paraphrase. +- Totals line reflects the actual counts shown. + +## See also + +- `handoff`, `session-history`, `recall`: same session data, different lens. + +## Troubleshooting + +See ../_shared/TROUBLESHOOTING.md if `memory_sessions` or `memory_recall` is not available. diff --git a/plugin/skills/remember/EXAMPLES.md b/plugin/skills/remember/EXAMPLES.md new file mode 100644 index 0000000..e220cb7 --- /dev/null +++ b/plugin/skills/remember/EXAMPLES.md @@ -0,0 +1,67 @@ +# remember worked examples + +## 1. Saving a decision + +User: "Remember that we decided to drop Redis and use the iii-engine state store for session caching." + +Invocation: + +```json +memory_save { + "content": "We decided to drop Redis and use the iii-engine state store for session caching to remove a moving part.", + "concepts": "session-caching, iii-state-store, dropped-redis", + "files": "" +} +``` + +Response: + +```json +{ "id": "7f3a9c21", "concepts": ["session-caching", "iii-state-store", "dropped-redis"], "saved": true } +``` + +Present: + +> Saved. Tagged it `session-caching`, `iii-state-store`, `dropped-redis`, so a +> later recall on caching or Redis will surface this decision. + +## 2. Saving a code-specific gotcha + +User: "Save this: the rate limiter in middleware/limit.ts counts per-IP, not per-user, so logged-in users on shared IPs collide." + +Invocation: + +```json +memory_save { + "content": "The rate limiter in middleware/limit.ts counts per-IP, not per-user, so logged-in users on shared IPs collide.", + "concepts": "rate-limiter, per-ip-bug, shared-ip-collision", + "files": "src/middleware/limit.ts" +} +``` + +Response: + +```json +{ "id": "b21d004e", "concepts": ["rate-limiter", "per-ip-bug", "shared-ip-collision"], "saved": true } +``` + +Present: + +> Saved memory `b21d004e` referencing `src/middleware/limit.ts`. Concepts: +> `rate-limiter`, `per-ip-bug`, `shared-ip-collision`. + +## 3. Vague request, no files + +User: "Remember this for later." + +Ask once for the substance, then save: + +```json +memory_save { + "content": "Staging deploys must run the migration job before the app rollout, never after.", + "concepts": "staging-deploy, migration-ordering, rollout-sequence", + "files": "" +} +``` + +Present the confirmation with the concepts echoed back. diff --git a/plugin/skills/remember/SKILL.md b/plugin/skills/remember/SKILL.md new file mode 100644 index 0000000..83e950d --- /dev/null +++ b/plugin/skills/remember/SKILL.md @@ -0,0 +1,61 @@ +--- +name: remember +description: Save an insight, decision, or learning to agentmemory's long-term storage with searchable concept tags. Use when the user says "remember this", "save this", "note that", "don't forget", or wants to preserve knowledge for future sessions. +argument-hint: "[what to remember]" +user-invocable: true +--- + +The user wants to save this to long-term memory: $ARGUMENTS + +## Quick start + +```json +memory_save { + "content": "We rotate JWT refresh tokens on every use; the old token is revoked server-side in auth/refresh.ts.", + "concepts": "jwt-refresh-rotation, token-revocation, auth-flow", + "files": "src/auth/refresh.ts" +} +``` + +Expected output: + +```text +Saved memory abc12345 with 3 concepts: jwt-refresh-rotation, token-revocation, auth-flow. +``` + +## Why + +A memory is only as useful as the terms that retrieve it. Tag with specific +concepts so a future `recall` finds it, and preserve the user's own phrasing. + +## Workflow + +1. Pull the core insight, decision, or fact out of `$ARGUMENTS`. +2. Extract 2-5 lowercased concept phrases. Prefer specific over generic + (`jwt-refresh-rotation` beats `auth`). +3. Extract referenced file paths (absolute or repo-relative). Empty if none. +4. Call `memory_save` with `content`, `concepts` (comma-separated string), and + `files` (comma-separated string). +5. Confirm the save and echo the concepts so the user knows the retrieval terms. + +## Anti-patterns + +WRONG: `concepts: "stuff, code, notes"` (generic tags nothing can find later). + +RIGHT: `concepts: "jwt-refresh-rotation, token-revocation"` (specific, retrievable). + +## Checklist + +- Content preserves the user's phrasing, not a paraphrase. +- Concepts are specific, lowercased, 2-5 items. +- File paths are real references, not guesses. +- Confirmation echoes the exact concepts tagged. + +## See also + +- `recall`: retrieve what you save here (the pair to this skill). +- `forget`: remove a memory you saved by mistake. + +## Troubleshooting + +See ../_shared/TROUBLESHOOTING.md if `memory_save` is not available. diff --git a/plugin/skills/session-history/EXAMPLES.md b/plugin/skills/session-history/EXAMPLES.md new file mode 100644 index 0000000..e58654f --- /dev/null +++ b/plugin/skills/session-history/EXAMPLES.md @@ -0,0 +1,59 @@ +# session-history worked examples + +## 1. Standard timeline + +User: "Show me the session history." + +Invocation: + +```json +memory_sessions { "limit": 20 } +``` + +Response: + +```json +{ + "sessions": [ + { "id": "7f3a9c21", "project": "app", "startedAt": "2026-06-07T09:00:00Z", + "status": "completed", "observationCount": 14, + "summary": "Reworked refresh rotation", + "highlights": [ { "type": "decision", "title": "Rotate refresh tokens on every use" } ] }, + { "id": "b21d004e", "project": "app", "startedAt": "2026-06-05T14:00:00Z", + "status": "completed", "observationCount": 9, + "highlights": [ { "type": "code", "title": "limit.ts counts per-IP" } ] } + ] +} +``` + +Present: + +> - `7f3a9c2` app, 2026-06-07 09:00, completed, 14 obs +> - decision: Rotate refresh tokens on every use (summary: Reworked refresh rotation) +> - `b21d004` app, 2026-06-05 14:00, completed, 9 obs +> - code: limit.ts counts per-IP + +## 2. Session with no observations + +If a returned session has `observationCount: 0`, list it but say so: + +> - `c98f110` app, 2026-06-04 11:00, abandoned, 0 obs (no recorded work) + +## 3. Empty history + +User: "What did we do last time?" + +```json +memory_sessions { "limit": 20 } +``` + +Response: + +```json +{ "sessions": [] } +``` + +Present: + +> No recorded sessions yet for this project. Once you work with memory capture +> on, they will show here. Use `remember` to save a note now. diff --git a/plugin/skills/session-history/SKILL.md b/plugin/skills/session-history/SKILL.md new file mode 100644 index 0000000..dd51479 --- /dev/null +++ b/plugin/skills/session-history/SKILL.md @@ -0,0 +1,61 @@ +--- +name: session-history +description: Show what happened in recent past sessions on this project as a clean timeline. Use when the user asks "what did we do last time", "session history", "past sessions", or wants an overview of previous work. +user-invocable: true +--- + +The user wants an overview of recent sessions on this project. + +## Quick start + +```json +memory_sessions { "limit": 20 } +``` + +Expected output: + +```text +7f3a9c2 · app · 2026-06-07 09:00 · completed · 14 obs + - decision: Rotate refresh tokens on every use +b21d004 · app · 2026-06-05 14:00 · completed · 9 obs + - code: limit.ts counts per-IP +``` + +## Why + +Only show sessions and observations the tool returned. An empty history is a +real answer, never a cue to invent past work. + +## Workflow + +1. Call `memory_sessions` with `limit: 20` for a meaningful window. +2. Present in reverse chronological order: session id (first 8), project, start + time, status. +3. For sessions with observations, show the key highlights (type plus title). +4. Note the total observation count per session. +5. When a session summary exists, surface its title and the key decisions. + +## Anti-patterns + +WRONG: the tool returns two sessions, you describe "several sessions of steady +progress" and add ones you remember from the conversation. + +RIGHT: show exactly the two sessions returned, each with its real id, status, and +observation count. + +## Checklist + +- Every session shown came from the tool response. +- Order is reverse-chronological. +- Per-session observation counts match the response. +- No session or highlight was invented or merged. + +## See also + +- `recap`: same data grouped by date with highlights. +- `handoff`: jump straight into the most recent session. +- `recall`: search across all sessions by topic. + +## Troubleshooting + +See ../_shared/TROUBLESHOOTING.md if `memory_sessions` is not available. diff --git a/plugin/skills/write-agentmemory-skill/SKILL.md b/plugin/skills/write-agentmemory-skill/SKILL.md new file mode 100644 index 0000000..d46a191 --- /dev/null +++ b/plugin/skills/write-agentmemory-skill/SKILL.md @@ -0,0 +1,40 @@ +--- +name: write-agentmemory-skill +description: The house format and rules for writing or updating an agentmemory skill. Use when adding a new skill, restructuring an existing one, or reviewing a skill contribution for consistency. +user-invocable: false +--- + +agentmemory skills follow one tiered format so they stay skimmable, accurate, and current. Match it exactly. + +## Directory layout + +```text +plugin/skills// + SKILL.md (required, under 100 lines) + REFERENCE.md (optional, dense facts; auto-generate data tables) + EXAMPLES.md (optional, worked transcripts) +``` + +## SKILL.md rules + +- Frontmatter: `name`, `description`, optional `argument-hint`, and `user-invocable`. Set `user-invocable: true` only for skills the user runs as a slash command; reference and knowledge skills are `false`. +- Description is two sentences and the only thing the agent sees when deciding to load the skill. Sentence one states the capability. Sentence two starts "Use when" and lists concrete triggers. Keep it distinct from sibling skills, under 1024 chars, third person. +- Body order: Quick start (one concrete example), Why (the governing principle), Workflow (numbered steps with decision gates), Anti-patterns (a WRONG vs RIGHT callout for the top mistake), Checklist, See also (cross-link siblings), Reference or Troubleshooting pointer. +- Stay under 100 lines. Move dense facts to REFERENCE.md and examples to EXAMPLES.md. +- Cross-references link one level deep only. Shared recovery steps live in `../_shared/TROUBLESHOOTING.md`, never inlined. + +## Keep it current + +Facts that exist in source (tool names and parameters, REST endpoints, env vars, connect adapters, hook events) are generated, never hand-typed. Edit the source, then run `npm run skills:gen`. CI runs `npm run skills:check` and fails on drift, so generated tables cannot fall behind the code. + +## Style + +No external or competitor product names. No emojis. No em-dashes. No filler. State the thing and stop. + +## Checklist + +- Description has a "Use when" sentence with real triggers. +- SKILL.md is under 100 lines. +- No time-sensitive claims and no duplicated troubleshooting block. +- Concrete example present; generated facts come from the generator. +- Cross-links resolve and go one level deep. diff --git a/scripts/backfill-imported-sessions.sh b/scripts/backfill-imported-sessions.sh new file mode 100755 index 0000000..a247a57 --- /dev/null +++ b/scripts/backfill-imported-sessions.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# Backfill memory artifacts for sessions imported via `agentmemory import-jsonl`. +# +# The import path only persists Session + Observation rows (via synthetic, +# zero-LLM compression) and the deterministic crystal/lesson derivation. +# It does NOT call mem::summarize, so the semantic/procedural/reflect tiers +# of the consolidation pipeline have nothing to roll up. +# +# This script walks every session tagged `jsonl-import` and: +# 1. POSTs /agentmemory/summarize per session (LLM call) +# 2. POSTs /agentmemory/consolidate-pipeline once at the end +# +# Graph extraction (/agentmemory/graph/extract) is intentionally skipped — +# its API takes a per-observation payload, which is cost-prohibitive for +# bulk imports. `reflect` falls back to a no-graph clustering mode. +# +# Usage: +# scripts/backfill-imported-sessions.sh --dry-run +# scripts/backfill-imported-sessions.sh --limit 5 +# scripts/backfill-imported-sessions.sh # process all + +set -euo pipefail + +URL="${AGENTMEMORY_URL:-http://localhost:3111}" +DRY_RUN=0 +LIMIT=0 # 0 = no limit +ONLY_TAG="jsonl-import" +SKIP_CONSOLIDATE=0 +SKIP_AGENTS=0 # drop sessions whose project starts with "agent-" +MAX_OBS=0 # 0 = no cap; skip sessions with more observations than this +DEBUG_ON_ERROR=0 # on failure, dump session metadata + obs to DEBUG_DIR +DEBUG_DIR="${AGENTMEMORY_DEBUG_DIR:-./agentmemory-debug}" +PROJECT_PATTERN="" # jq test() regex against .project; "" means no filter + +# Cost-estimate knobs (defaults tuned for DeepSeek V4 Flash on DeepInfra: +# $0.14 / 1M input, $0.28 / 1M output). Override via env if needed. +COST_IN_PER_1M="${AGENTMEMORY_COST_IN_PER_1M:-0.14}" +COST_OUT_PER_1M="${AGENTMEMORY_COST_OUT_PER_1M:-0.28}" +# Rough token weight per compressed observation, derived from inspecting +# real synthetic-compression payloads in the kv store (mostly 100-300 tok, +# heavy-tailed). Override if your sessions are unusually verbose. +TOKENS_PER_OBS="${AGENTMEMORY_TOKENS_PER_OBS:-200}" +# Reserved per-call output budget (XML summary is small). +TOKENS_OUT_PER_SESSION="${AGENTMEMORY_TOKENS_OUT_PER_SESSION:-500}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --limit) LIMIT="${2:?--limit needs a number}"; shift 2 ;; + --tag) ONLY_TAG="${2:?--tag needs a value (use empty string for all)}"; shift 2 ;; + --skip-consolidate) SKIP_CONSOLIDATE=1; shift ;; + --skip-agents) SKIP_AGENTS=1; shift ;; + --max-obs) MAX_OBS="${2:?--max-obs needs a number}"; shift 2 ;; + --debug-on-error) DEBUG_ON_ERROR=1; shift ;; + --project-pattern) PROJECT_PATTERN="${2:?--project-pattern needs a regex}"; shift 2 ;; + -h|--help) + sed -n '2,28p' "$0" + exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 2 ;; + esac +done + +for bin in curl jq; do + command -v "$bin" >/dev/null || { echo "missing dependency: $bin" >&2; exit 1; } +done + +# Curl timeout profiles. Metadata reads (livez, sessions list, observations +# pull for debug dumps) should fail fast and retry transient blips. The LLM +# work calls (summarize, consolidate) intentionally have no --retry and a +# wide --max-time: each call can legitimately take minutes for chunked +# summarize on large sessions, and retrying a half-finished LLM job is +# expensive both in dollars and in duplicated server-side work. +META_CURL_OPTS=(--connect-timeout 10 --max-time 30 --retry 2 --retry-delay 1) +WORK_CURL_OPTS=(--connect-timeout 10 --max-time 1800) + +echo "agentmemory backfill — server: $URL" +[[ "$DRY_RUN" == 1 ]] && echo "DRY RUN: no POSTs will be made." + +# --- liveness --- +if ! curl -fsS "${META_CURL_OPTS[@]}" "$URL/agentmemory/livez" >/dev/null; then + echo "server not reachable at $URL (try: npx @agentmemory/agentmemory)" >&2 + exit 1 +fi + +# --- collect session ids --- +sessions_json="$(curl -fsS "${META_CURL_OPTS[@]}" "$URL/agentmemory/sessions")" +filter='.sessions[] | select(.status=="completed")' +if [[ -n "$ONLY_TAG" ]]; then + filter+=" | select((.tags // []) | index(\"$ONLY_TAG\"))" +fi +if [[ "$SKIP_AGENTS" == 1 ]]; then + filter+=' | select((.project // "") | startswith("agent-") | not)' +fi +if [[ -n "$PROJECT_PATTERN" ]]; then + # jq's test() applies a regex against the project string. + filter+=" | select((.project // \"\") | test(\"$PROJECT_PATTERN\"))" +fi +if [[ "$MAX_OBS" -gt 0 ]]; then + filter+=" | select((.observationCount // 0) <= $MAX_OBS)" +fi +filter+=' | "\(.id)\t\(.observationCount // 0)\t\(.project // "")"' + +rows=() +while IFS= read -r line; do + rows+=("$line") +done < <(echo "$sessions_json" | jq -r "$filter") +total="${#rows[@]}" + +if [[ "$total" -eq 0 ]]; then + echo "no sessions matched (tag='$ONLY_TAG'); nothing to do." + exit 0 +fi + +if [[ "$LIMIT" -gt 0 && "$LIMIT" -lt "$total" ]]; then + rows=("${rows[@]:0:$LIMIT}") +fi + +echo "matched $total session(s); will process ${#rows[@]}." +total_obs=0 +for row in "${rows[@]}"; do + obs="$(cut -f2 <<<"$row")" + total_obs=$(( total_obs + obs )) +done +est_in=$(( total_obs * TOKENS_PER_OBS + ${#rows[@]} * 500 )) +est_out=$(( ${#rows[@]} * TOKENS_OUT_PER_SESSION )) +est_cost="$(awk -v i="$est_in" -v o="$est_out" -v ci="$COST_IN_PER_1M" -v co="$COST_OUT_PER_1M" \ + 'BEGIN { printf "%.2f", (i*ci + o*co) / 1000000 }')" + +echo "≈ ${#rows[@]} summarize LLM calls (one per session, covering $total_obs observations)" +printf '≈ %d input tok + %d output tok → $%s (rates: in=$%s/1M out=$%s/1M, %s tok/obs)\n' \ + "$est_in" "$est_out" "$est_cost" "$COST_IN_PER_1M" "$COST_OUT_PER_1M" "$TOKENS_PER_OBS" +echo + +if [[ "$DRY_RUN" == 1 ]]; then + printf '%-40s %10s %s\n' "session" "obs" "project" + for row in "${rows[@]}"; do + id="$(cut -f1 <<<"$row")" + obs="$(cut -f2 <<<"$row")" + proj="$(cut -f3 <<<"$row")" + printf '%-40s %10s %s\n' "$id" "$obs" "$proj" + done + echo + echo "(dry run) next steps if you re-run without --dry-run:" + echo " for each session above: POST $URL/agentmemory/summarize {sessionId}" + if [[ "$SKIP_CONSOLIDATE" == 0 ]]; then + echo " then: POST $URL/agentmemory/consolidate-pipeline {}" + fi + exit 0 +fi + +# --- summarize loop --- +if [[ "$DEBUG_ON_ERROR" == 1 ]]; then + mkdir -p "$DEBUG_DIR" + echo "debug mode: failed calls will dump to $DEBUG_DIR/" + echo +fi + +dump_failure() { + local id="$1" obs="$2" resp="$3" + # Replace anything outside [A-Za-z0-9._-] with `_` before joining with + # DEBUG_DIR. Session IDs from the API are UUIDs in practice, but the + # server doesn't enforce that — a hostile or buggy id containing `/` or + # `..` would otherwise escape the debug directory. + local safe_id + safe_id="$(printf '%s' "$id" | tr -c 'A-Za-z0-9._-' '_')" + local file="$DEBUG_DIR/${safe_id}.json" + # Pull the raw observations (what would have gone into the prompt) so the + # operator can reconstruct the upstream payload locally. We also compute + # narrative size stats so size-related rejections are immediately visible. + # Stream observations through stdin (avoids exec-arg overflow on + # multi-thousand-obs sessions — macOS argv ceiling is ~256k). + # `--get --data-urlencode` percent-encodes the session id so special + # characters can't corrupt the query string. + curl -fsS "${META_CURL_OPTS[@]}" --get \ + --data-urlencode "sessionId=$id" \ + "$URL/agentmemory/observations" \ + | jq \ + --arg id "$id" \ + --argjson obsCount "$obs" \ + --arg url "$URL/agentmemory/summarize" \ + --argjson response "$resp" \ + '. as $root + | .observations as $obs + | { + sessionId: $id, + observationCount: $obsCount, + request: { url: $url, method: "POST", body: { sessionId: $id } }, + response: $response, + observations: $obs, + stats: { + totalNarrativeBytes: ($obs | map(.narrative // "" | length) | add // 0), + maxNarrativeBytes: ($obs | map(.narrative // "" | length) | max // 0), + titleHistogram: ($obs | group_by(.title) | map({title: .[0].title, count: length}) | sort_by(-.count)) + } + }' >"$file" + echo " → $file" +} + +ok=0; skipped=0; failed=0 +i=0 +for row in "${rows[@]}"; do + i=$(( i + 1 )) + id="$(cut -f1 <<<"$row")" + obs="$(cut -f2 <<<"$row")" + + body="$(jq -nc --arg id "$id" '{sessionId:$id}')" + resp="$(curl -sS "${WORK_CURL_OPTS[@]}" -X POST "$URL/agentmemory/summarize" \ + -H 'content-type: application/json' --data "$body" || echo '{"success":false,"error":"curl_failed"}')" + # iii's HTTP layer occasionally returns non-JSON (HTML 5xx, empty body + # on timeout, etc.). Validate before parsing so `set -e` doesn't abort + # the whole backfill loop on a single bad response. + if jq -e . >/dev/null 2>&1 <<<"$resp"; then + status="$(jq -r '.success // false' <<<"$resp")" + err="$(jq -r '.error // ""' <<<"$resp")" + title="$(jq -r '.summary.title // ""' <<<"$resp")" + else + status="false" + err="invalid_json_response" + title="" + fi + + if [[ "$status" == "true" ]]; then + ok=$(( ok + 1 )) + printf '[%3d/%3d] OK %s obs=%-5s %s\n' "$i" "${#rows[@]}" "$id" "$obs" "$title" + elif [[ "$err" == "no_observations" || "$err" == "no_provider" ]]; then + skipped=$(( skipped + 1 )) + printf '[%3d/%3d] SKIP %s obs=%-5s %s\n' "$i" "${#rows[@]}" "$id" "$obs" "$err" + else + failed=$(( failed + 1 )) + printf '[%3d/%3d] FAIL %s obs=%-5s %s\n' "$i" "${#rows[@]}" "$id" "$obs" "$err" + [[ "$DEBUG_ON_ERROR" == 1 ]] && dump_failure "$id" "$obs" "$resp" + fi +done + +echo +echo "summarize: ok=$ok skipped=$skipped failed=$failed" + +# --- consolidate --- +if [[ "$SKIP_CONSOLIDATE" == 1 ]]; then + echo "skipping consolidate-pipeline (--skip-consolidate)" + exit 0 +fi + +if [[ "$ok" -eq 0 ]]; then + echo "no summaries produced; skipping consolidate-pipeline." + exit 0 +fi + +echo +echo "running consolidate-pipeline …" +resp="$(curl -sS "${WORK_CURL_OPTS[@]}" -X POST "$URL/agentmemory/consolidate-pipeline" \ + -H 'content-type: application/json' --data '{}' || echo '{"success":false,"error":"curl_failed"}')" +if jq -e . >/dev/null 2>&1 <<<"$resp"; then + echo "$resp" | jq . +else + echo "consolidate-pipeline returned non-JSON (likely a timeout or upstream error):" + printf '%s\n' "$resp" | head -c 500 + echo +fi diff --git a/scripts/check-env-example.mjs b/scripts/check-env-example.mjs new file mode 100644 index 0000000..ae5ea85 --- /dev/null +++ b/scripts/check-env-example.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// +// Sync-check: every env var read by `src/` MUST be documented in +// `.env.example`. Runs in CI as a soft guard rail — keeps `.env.example` +// from drifting behind real config-surface additions. +// +// Usage: +// node scripts/check-env-example.mjs +// +// Returns 0 when in sync, 1 with a diff when out of sync. + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname; +const SRC = join(ROOT, "src"); +const ENV_FILE = join(ROOT, ".env.example"); + +// Env vars read by the runtime but NOT user-facing config — these are +// either process-injected (HOME, PATH, USERPROFILE), set by the build / +// wrapper (NODE_*, npm_*), or set by tests (VITEST, *_TEST_*). Skipping +// them keeps `.env.example` a documented config surface rather than an +// inventory of every getenv anywhere in the codebase. +const RUNTIME_ONLY = new Set([ + "HOME", + "PATH", + "USERPROFILE", + "NODE_ENV", + "AGENTMEMORY_SDK_CHILD", +]); + +// Walk src/ for .ts / .mts / .mjs / .js files (excluding `.d.ts` declarations +// and dotfile dirs / node_modules). test/ lives outside src/ so it never enters. +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const s = statSync(full); + if (s.isDirectory()) { + if (entry === "node_modules" || entry.startsWith(".")) continue; + out.push(...walk(full)); + } else if (/\.(ts|mts|mjs|js)$/.test(entry) && !entry.endsWith(".d.ts")) { + out.push(full); + } + } + return out; +} + +// Multiple patterns: +// process.env["KEY"] — direct access +// env["KEY"] — local alias inside detectProvider, etc. +// getEnvVar("KEY") — helper from src/config.ts +// env: ProcessEnv → env.KEY — caught as `env["KEY"]` only; if you add +// a dotted-access path, extend the regex. +const PATTERNS = [ + // Direct map index: process.env["KEY"], env["KEY"], getMergedEnv()["KEY"]. + // The trailing `]\s*` form covers `…)["KEY"]` and `…env["KEY"]`. + /\[\s*"([A-Z][A-Z0-9_]+)"\s*\]/g, + /getEnvVar\(\s*"([A-Z][A-Z0-9_]+)"\s*\)/g, +]; +const used = new Set(); +for (const file of walk(SRC)) { + const text = readFileSync(file, "utf8"); + for (const pat of PATTERNS) { + pat.lastIndex = 0; + let m; + while ((m = pat.exec(text)) !== null) { + const name = m[1]; + if (!RUNTIME_ONLY.has(name)) used.add(name); + } + } +} + +const envText = readFileSync(ENV_FILE, "utf8"); +const documented = new Set(); +for (const line of envText.split("\n")) { + const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)=/); + if (m) documented.add(m[1]); +} + +const missing = [...used].filter((k) => !documented.has(k)).sort(); +const orphan = [...documented].filter((k) => !used.has(k)).sort(); + +if (missing.length === 0 && orphan.length === 0) { + console.log(`env-example: in sync (${used.size} keys documented)`); + process.exit(0); +} + +if (missing.length > 0) { + console.error( + `env-example: MISSING from .env.example — add documentation for these keys:`, + ); + for (const k of missing) console.error(` - ${k}`); +} +if (orphan.length > 0) { + console.error( + `env-example: ORPHAN in .env.example — no longer read by src/, remove or move to runtime-only allowlist:`, + ); + for (const k of orphan) console.error(` - ${k}`); +} +process.exit(1); diff --git a/scripts/skills/check.ts b/scripts/skills/check.ts new file mode 100644 index 0000000..860e1e7 --- /dev/null +++ b/scripts/skills/check.ts @@ -0,0 +1,80 @@ +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "..", ".."); +const SKILLS = join(ROOT, "plugin", "skills"); + +const errors: string[] = []; +const MAX_LINES = 100; +const DUP_MARKER = "/plugin list"; + +function parseFrontmatter(text: string): Record | null { + if (!text.startsWith("---")) return null; + const end = text.indexOf("\n---", 3); + if (end === -1) return null; + const body = text.slice(3, end); + const out: Record = {}; + for (const line of body.split("\n")) { + const m = /^([a-zA-Z_-]+):\s*(.*)$/.exec(line.trim()); + if (m) out[m[1]] = m[2]; + } + return out; +} + +const dirs = readdirSync(SKILLS, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !e.name.startsWith("_")) + .map((e) => e.name); + +for (const name of dirs) { + const skillFile = join(SKILLS, name, "SKILL.md"); + if (!existsSync(skillFile)) { + errors.push(`${name}: missing SKILL.md`); + continue; + } + const text = readFileSync(skillFile, "utf8"); + const rel = `plugin/skills/${name}/SKILL.md`; + + const fm = parseFrontmatter(text); + if (!fm) { + errors.push(`${rel}: missing or malformed frontmatter`); + } else { + if (!fm.name) errors.push(`${rel}: frontmatter missing 'name'`); + if (fm.name && fm.name !== name) errors.push(`${rel}: frontmatter name '${fm.name}' != dir '${name}'`); + if (!fm.description) errors.push(`${rel}: frontmatter missing 'description'`); + else { + if (!/use when/i.test(fm.description)) errors.push(`${rel}: description must contain a "Use when ..." trigger sentence`); + if (fm.description.length > 1024) errors.push(`${rel}: description exceeds 1024 chars`); + } + } + + const lines = text.split("\n").length; + if (lines > MAX_LINES) errors.push(`${rel}: ${lines} lines (max ${MAX_LINES}); move detail into REFERENCE.md or EXAMPLES.md`); + + const body = fm ? text.slice(text.indexOf("\n---", 3) + 4) : text; + if (body.includes(DUP_MARKER)) { + errors.push(`${rel}: inlines the shared troubleshooting block; reference ../_shared/TROUBLESHOOTING.md instead`); + } +} + +if (!existsSync(join(SKILLS, "_shared", "TROUBLESHOOTING.md"))) { + errors.push(`_shared/TROUBLESHOOTING.md is missing`); +} + +const pluginJson = join(ROOT, "plugin", "plugin.json"); +if (existsSync(pluginJson)) { + const desc = (JSON.parse(readFileSync(pluginJson, "utf8")).description as string) ?? ""; + const m = /(\d+)\s+skills/.exec(desc); + if (!m) errors.push(`plugin/plugin.json: description should state the skill count as "N skills"`); + else if (Number(m[1]) !== dirs.length) { + errors.push(`plugin/plugin.json: description says ${m[1]} skills but ${dirs.length} skill dirs exist`); + } +} + +if (errors.length) { + console.error("Skill lint failed:"); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} +console.log(`Skill lint passed: ${dirs.length} skills checked.`); diff --git a/scripts/skills/generate.ts b/scripts/skills/generate.ts new file mode 100644 index 0000000..44ccf94 --- /dev/null +++ b/scripts/skills/generate.ts @@ -0,0 +1,170 @@ +import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getAllTools, ESSENTIAL_TOOLS } from "../../src/mcp/tools-registry.js"; +import { ADAPTERS } from "../../src/cli/connect/index.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "..", ".."); +const SKILLS = join(ROOT, "plugin", "skills"); +const SRC = join(ROOT, "src"); + +const check = process.argv.includes("--check"); + +function clean(s: string): string { + return s.replace(/\s*[—–]\s*/g, ", "); +} + +function block(key: string, body: string): { open: string; close: string; full: string } { + const open = ``; + const close = ``; + return { open, close, full: `${open}\n${body.trim()}\n${close}` }; +} + +function applyBlock(file: string, key: string, body: string): void { + const { open, close, full } = block(key, body); + const existing = existsSync(file) ? readFileSync(file, "utf8") : ""; + let next: string; + if (existing.includes(open) && existing.includes(close)) { + const start = existing.indexOf(open); + const end = existing.indexOf(close) + close.length; + next = existing.slice(0, start) + full + existing.slice(end); + } else if (existing.trim()) { + next = `${existing.trimEnd()}\n\n${full}\n`; + } else { + next = `${full}\n`; + } + if (check) { + if (existing !== next) { + console.error(`DRIFT: ${file.replace(ROOT + "/", "")} (AUTOGEN:${key} out of date — run \`npm run skills:gen\`)`); + process.exitCode = 1; + } + return; + } + if (existing !== next) { + writeFileSync(file, next); + console.log(`wrote AUTOGEN:${key} -> ${file.replace(ROOT + "/", "")}`); + } +} + +function walk(dir: string, out: string[] = []): string[] { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, e.name); + if (e.isDirectory()) { + if (e.name === "node_modules" || e.name === "dist") continue; + walk(full, out); + } else if (e.name.endsWith(".ts")) { + out.push(full); + } + } + return out; +} + +function mdEscape(s: string): string { + return clean(s.replace(/\|/g, "\\|").replace(/\n/g, " ")).trim(); +} + +function tools(): string { + const all = getAllTools(); + const lines = [ + `agentmemory exposes ${all.length} MCP tools. ${ESSENTIAL_TOOLS.size} are in the lean core set (\`--tools core\` or \`AGENTMEMORY_TOOLS=core\`); the rest load with \`--tools all\` (default).`, + "", + "| Tool | Core | Parameters | Purpose |", + "| --- | --- | --- | --- |", + ]; + for (const t of all.sort((a, b) => a.name.localeCompare(b.name))) { + const required = new Set(t.inputSchema.required ?? []); + const params = Object.entries(t.inputSchema.properties ?? {}) + .map(([n, s]) => `\`${n}\`${required.has(n) ? "*" : ""}: ${s.type}`) + .join(", ") || "none"; + const core = ESSENTIAL_TOOLS.has(t.name) ? "yes" : ""; + lines.push(`| \`${t.name}\` | ${core} | ${mdEscape(params)} | ${mdEscape(t.description)} |`); + } + lines.push("", "`*` marks required parameters."); + return lines.join("\n"); +} + +function rest(): string { + const text = readFileSync(join(SRC, "triggers", "api.ts"), "utf8"); + const found: { path: string; method: string }[] = []; + const re = /api_path:\s*"([^"]+)"/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const path = m[1]; + const win = text.slice(Math.max(0, m.index - 140), m.index + 140); + const mm = /http_method:\s*"([A-Z]+)"/.exec(win); + found.push({ path, method: mm ? mm[1] : "POST" }); + } + const seen = new Set(); + const rows = found + .filter((e) => (seen.has(e.path) ? false : (seen.add(e.path), true))) + .sort((a, b) => a.path.localeCompare(b.path)); + const lines = [ + `The REST API is the primary surface. All paths are under \`http://localhost:3111\` (override with \`--port\`). When \`AGENTMEMORY_SECRET\` is set, send \`Authorization: Bearer $AGENTMEMORY_SECRET\`; localhost is otherwise open.`, + "", + `${rows.length} registered endpoints:`, + "", + "| Method | Path |", + "| --- | --- |", + ...rows.map((e) => `| ${e.method} | \`${e.path}\` |`), + ]; + return lines.join("\n"); +} + +function env(): string { + const files = walk(SRC); + const vars = new Set(); + for (const f of files) { + const text = readFileSync(f, "utf8"); + const re = /AGENTMEMORY_[A-Z0-9_]+/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + if (!m[0].endsWith("__")) vars.add(m[0]); + } + } + const sorted = [...vars].sort(); + const lines = [ + `Configuration is read from the environment and from \`~/.agentmemory/.env\` (no \`export\` prefix). ${sorted.length} recognized variables:`, + "", + ...sorted.map((v) => `- \`${v}\``), + ]; + return lines.join("\n"); +} + +function agents(): string { + const lines = [ + `\`agentmemory connect \` wires the memory server into a host agent. ${ADAPTERS.length} adapters:`, + "", + "| Agent | Name | Protocol |", + "| --- | --- | --- |", + ]; + for (const a of [...ADAPTERS].sort((x, y) => x.name.localeCompare(y.name))) { + const note = (a.protocolNote ?? "").replace(/^[→\s]+/, ""); + lines.push(`| ${mdEscape(a.displayName)} | \`${a.name}\` | ${mdEscape(note) || "MCP"} |`); + } + return lines.join("\n"); +} + +function hooks(): string { + const file = join(ROOT, "plugin", "hooks", "hooks.json"); + const json = JSON.parse(readFileSync(file, "utf8")) as { hooks?: Record }; + const events = Object.keys(json.hooks ?? {}).sort(); + const lines = [ + `The Claude Code plugin registers hooks on ${events.length} lifecycle events to capture observations automatically:`, + "", + ...events.map((e) => `- \`${e}\``), + ]; + return lines.join("\n"); +} + +applyBlock(join(SKILLS, "agentmemory-mcp-tools", "REFERENCE.md"), "tools", tools()); +applyBlock(join(SKILLS, "agentmemory-rest-api", "REFERENCE.md"), "rest", rest()); +applyBlock(join(SKILLS, "agentmemory-config", "REFERENCE.md"), "env", env()); +applyBlock(join(SKILLS, "agentmemory-agents", "REFERENCE.md"), "agents", agents()); +applyBlock(join(SKILLS, "agentmemory-hooks", "REFERENCE.md"), "hooks", hooks()); + +if (check && process.exitCode) { + console.error("\nSkill reference docs are stale. Run: npm run skills:gen"); +} else if (!check) { + console.log("skills reference generation complete"); +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..95153ba --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,30 @@ +import { timingSafeEqual, createHmac, randomBytes } from "node:crypto"; + +const hmacKey = randomBytes(32); +export const VIEWER_NONCE_PLACEHOLDER = "__AGENTMEMORY_VIEWER_NONCE__"; + +export function timingSafeCompare(a: string, b: string): boolean { + const hmacA = createHmac("sha256", hmacKey).update(a).digest(); + const hmacB = createHmac("sha256", hmacKey).update(b).digest(); + return timingSafeEqual(hmacA, hmacB); +} + +export function createViewerNonce(): string { + return randomBytes(16).toString("base64url"); +} + +export function buildViewerCsp(nonce: string): string { + return [ + "default-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + "object-src 'none'", + "form-action 'none'", + `script-src 'nonce-${nonce}'`, + "script-src-attr 'none'", + "style-src 'unsafe-inline'", + "connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*", + "img-src 'self'", + "font-src 'self'", + ].join("; "); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..93b9074 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,2992 @@ +#!/usr/bin/env node + +import { + spawn, + execFileSync, + spawnSync, + type ChildProcess, +} from "node:child_process"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join, dirname, delimiter as PATH_DELIMITER } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir, platform } from "node:os"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; + +// Semantic color helpers. clack strips ANSI for box-width math +// (stripVTControlCharacters + string-width), so coloring inside p.note +// keeps borders aligned. Centralized here so the palette stays consistent +// across every command's output. +const c = { + url: pc.cyan, + ok: pc.green, + warn: pc.yellow, + err: pc.red, + cmd: (s: string) => pc.bold(pc.cyan(s)), + label: pc.bold, + dim: pc.dim, + accent: (s: string) => pc.bold(pc.yellow(s)), +}; +import { generateId } from "./state/schema.js"; +import { + buildDiagnostics, + dryRunPlan, + parseEnvFile, + type Diagnostic, + type DiagnosticFixResult, + type DoctorContext, + type DoctorEffects, +} from "./cli/doctor-diagnostics.js"; +import { + buildRemovePlan, + formatPlan, + legacyLocalBinIii, + type ConnectManifest, + type RemoveOptions, +} from "./cli/remove-plan.js"; +import { renderSplash } from "./cli/splash.js"; +import { isFirstRun, readPrefs, resetPrefs, writePrefs } from "./cli/preferences.js"; +import { runOnboarding } from "./cli/onboarding.js"; +import { setBootVerbose } from "./logger.js"; +import { VERSION } from "./version.js"; +import { getAllTools, ESSENTIAL_TOOLS } from "./mcp/tools-registry.js"; +import { knownAgents } from "./cli/connect/index.js"; + +const ALL_TOOLS_COUNT = getAllTools().length; +const CORE_TOOLS_COUNT = getAllTools().filter((t) => ESSENTIAL_TOOLS.has(t.name)).length; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const args = process.argv.slice(2); +const IS_WINDOWS = platform() === "win32"; +const IS_VERBOSE = + args.includes("--verbose") || + args.includes("-v") || + process.env["AGENTMEMORY_VERBOSE"] === "1" || + process.env["AGENTMEMORY_VERBOSE"] === "true"; + +// Propagate the resolved verbosity to the worker's boot logger so the +// 25-line `[agentmemory] X registered` stream is either dropped or +// printed verbatim. Without this the worker's default (env-only) would +// disagree with the CLI flag. +setBootVerbose(IS_VERBOSE); + +const IS_RESET = args.includes("--reset"); + +// --version / -V early exit. Print VERSION + exit before any side effects +// (engine boot, env load, dir mkdir). `-v` is taken by --verbose so we +// reserve `-V` (capital) for version per POSIX convention. +if (args.includes("--version") || args.includes("-V")) { + process.stdout.write(`${VERSION}\n`); + process.exit(0); +} + +// Pinned iii-engine version. The unpinned `install.iii.dev/iii/main/install.sh` +// script tracks `latest`, which made every fresh agentmemory install pull +// engine 0.11.6 — and 0.11.6 introduces a new sandbox-everything-via- +// `iii worker add` worker model that agentmemory hasn't been refactored +// for yet (we still use the old `iii-exec watch` config-file model). The +// architectural mismatch surfaces as EPIPE reconnect loops and empty +// search results after save. Pin to v0.11.2 — the last engine that runs +// agentmemory's current worker model cleanly — until the refactor lands. +// Override env var AGENTMEMORY_III_VERSION lets users on the sandbox +// model already point at a newer engine without us cutting a release. +const IIPINNED_VERSION = + process.env["AGENTMEMORY_III_VERSION"] || "0.11.2"; + +// Map Node platform/arch → the asset name iii-hq/iii ships under +// https://github.com/iii-hq/iii/releases/download/iii/v/ +function iiiReleaseAsset(): string | null { + const p = platform(); + const a = process.arch; + if (p === "darwin" && a === "arm64") + return "iii-aarch64-apple-darwin.tar.gz"; + if (p === "darwin" && a === "x64") + return "iii-x86_64-apple-darwin.tar.gz"; + if (p === "linux" && a === "x64") + return "iii-x86_64-unknown-linux-gnu.tar.gz"; + if (p === "linux" && a === "arm64") + return "iii-aarch64-unknown-linux-gnu.tar.gz"; + if (p === "linux" && a === "arm") + return "iii-armv7-unknown-linux-gnueabihf.tar.gz"; + if (p === "win32" && a === "x64") + return "iii-x86_64-pc-windows-msvc.zip"; + if (p === "win32" && a === "arm64") + return "iii-aarch64-pc-windows-msvc.zip"; + return null; +} + +function iiiReleaseUrl(): string | null { + const asset = iiiReleaseAsset(); + if (!asset) return null; + // Tag name is monorepo-prefixed: `iii/v0.11.2`. Slash is URL-encoded + // by GitHub when serving the download path, hence `iii/v...` not `iii%2Fv...`. + return `https://github.com/iii-hq/iii/releases/download/iii/v${IIPINNED_VERSION}/${asset}`; +} + +function vlog(msg: string): void { + if (IS_VERBOSE) p.log.info(`[verbose] ${msg}`); +} + +function wrapList(items: readonly string[], indent: number, width = 78): string { + const lines: string[] = []; + let line = ""; + for (const item of items) { + const joined = line ? `${line}, ${item}` : item; + if (line && indent + joined.length > width) { + lines.push(`${line},`); + line = item; + } else { + line = joined; + } + } + lines.push(line); + return lines.join(`\n${" ".repeat(indent)}`); +} + +if (args.includes("--help") || args.includes("-h")) { + console.log(` +agentmemory — persistent memory for AI coding agents + +Usage: agentmemory [command] [options] + +Commands: + (default) Start agentmemory worker + init Copy bundled .env.example to ~/.agentmemory/.env if absent + connect [agent] Wire agentmemory into an installed agent + (${wrapList(knownAgents(), 21)}). + No arg = interactive picker. --all wires every detected agent. + --dry-run shows what would change. --force re-installs. + status Show connection status, memory count, flags, and health + doctor Interactive diagnostic + fixer. [F]ix · [S]kip · [?]more · [Q]uit + --all: apply every fix without prompting (CI) + --dry-run: show what each fix would do, don't execute + remove Cleanly uninstall agentmemory (pidfile, state, .env, binaries). + --force: skip confirmations · --keep-data: keep memory data + demo [--serve] Seed sample sessions and show recall in action. + --serve boots the server, runs the demo, and stops it + in one command (no second terminal). + upgrade Upgrade local deps + iii runtime (best effort) + stop [--force] Stop the running iii-engine started by this CLI. + --force bypasses the Docker-heuristic guard and signals + whatever pidfile+lsof report on the REST port (use when + the engine was started natively but state file is missing). + mcp Start standalone MCP shim — opt-in surface for MCP-only clients + (Cursor, Gemini CLI, etc). REST always available at :3111. + import-jsonl [p] Import Claude Code JSONL transcripts (default: ~/.claude/projects) + --max-files | --max-files=: override scan cap (default 200, max 1000; + out-of-range is rejected; for trees >1000 files, batch by subdirectory) + +Options: + --help, -h Show this help + --verbose, -v Show engine stderr, boot log, and diagnostic info + --reset Wipe ~/.agentmemory/preferences.json and re-run onboarding + --tools all|core Tool visibility (default: all = ${ALL_TOOLS_COUNT} tools; core = ${CORE_TOOLS_COUNT} essentials) + --no-engine Skip auto-starting iii-engine + --port Override REST port (default: 3111). Streams (N+1), viewer + (N+2), and iii engine (N+46023) auto-derive from N so a + single flag relocates the whole quartet. + --instance Shortcut for --port (3111 + N*100) to run multiple + daemons side-by-side without env gymnastics. + --instance 1 -> 3211/3212/3213/49234, etc. (max N=50) + +Environment: + AGENTMEMORY_URL Full REST base URL (e.g. http://localhost:3111). + Honored by status, doctor, and MCP shim commands. + AGENTMEMORY_USE_DOCKER=1 Prefer the bundled docker-compose path over the + native iii-engine binary on first run. + AGENTMEMORY_III_VERSION Override pinned iii-engine version (default ${IIPINNED_VERSION}). + AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS + Window (seconds) for the smart-search follow-up diagnostic + (default 30). Long values overcount, short values undercount. + +Quick start: + npx @agentmemory/agentmemory # start with local iii-engine or Docker + npx @agentmemory/agentmemory demo # see semantic recall in 30 seconds + npx @agentmemory/agentmemory doctor # diagnose config + feature flags + npx @agentmemory/agentmemory status # health + memory count + flags + npx @agentmemory/agentmemory upgrade # upgrade agentmemory + iii runtime + npx @agentmemory/agentmemory mcp # standalone MCP server (no engine) + npx @agentmemory/mcp # same as above (shim package) +`); + process.exit(0); +} + +const toolsIdx = args.indexOf("--tools"); +if (toolsIdx !== -1 && args[toolsIdx + 1]) { + const toolsMode = args[toolsIdx + 1]!; + if (toolsMode !== "all" && toolsMode !== "core") { + p.log.warn( + `Unknown --tools value "${toolsMode}" (valid: all, core); falling back to all.`, + ); + } + process.env["AGENTMEMORY_TOOLS"] = toolsMode; +} + +const portIdx = args.indexOf("--port"); +if (portIdx !== -1 && args[portIdx + 1]) { + process.env["III_REST_PORT"] = args[portIdx + 1]; +} + +// `--instance N` picks a 100-port block off the 3111 base so multiple +// agentmemory daemons can coexist on one host without env-var +// gymnastics. `--instance 0` keeps the canonical 3111/3112/3113/49134 +// quartet; `--instance 1` → 3211/3212/3213/49234; etc. REST acts as the +// anchor — streams/viewer/engine derive from it via fixed offsets below +// unless an env explicitly pins each one. +const instanceIdx = args.indexOf("--instance"); +if (instanceIdx !== -1 && args[instanceIdx + 1]) { + const n = parseInt(args[instanceIdx + 1] || "", 10); + if (Number.isFinite(n) && n >= 0 && n <= 50) { + const base = 3111 + n * 100; + if (!process.env["III_REST_PORT"]) { + process.env["III_REST_PORT"] = String(base); + } + } +} + +const skipEngine = args.includes("--no-engine"); + +function getRestPort(): number { + const url = process.env["AGENTMEMORY_URL"]; + if (url) { + try { + const parsed = new URL(url).port; + if (parsed) return parseInt(parsed, 10); + } catch {} + } + return parseInt(process.env["III_REST_PORT"] || "3111", 10) || 3111; +} + +function getBaseUrl(): string { + const url = process.env["AGENTMEMORY_URL"]; + if (url) return url.replace(/\/+$/, ""); + return `http://localhost:${getRestPort()}`; +} + +let discoveredViewerPort: number | null = null; + +export async function discoverViewerPort(): Promise { + if (discoveredViewerPort !== null) return; + try { + const res = await fetch(`${getBaseUrl()}/agentmemory/livez`, { + signal: AbortSignal.timeout(1000), + }); + if (res.ok) { + const data = await res.json() as { viewerPort?: number | null }; + if (typeof data.viewerPort === "number") { + discoveredViewerPort = data.viewerPort; + } + } + } catch {} +} + +function getViewerUrl(): string { + const envUrl = process.env["AGENTMEMORY_VIEWER_URL"]; + if (envUrl) return envUrl.replace(/\/+$/, ""); + + if (discoveredViewerPort !== null) { + try { + const u = new URL(getBaseUrl()); + return `${u.protocol}//${u.hostname}:${discoveredViewerPort}`; + } catch { + return `http://localhost:${discoveredViewerPort}`; + } + } + + try { + const u = new URL(getBaseUrl()); + const vPort = + parseInt(process.env["III_VIEWER_PORT"] || "", 10) || + (parseInt(u.port || "3111", 10) || 3111) + 2; + return `${u.protocol}//${u.hostname}:${vPort}`; + } catch { + const vPort = + parseInt(process.env["III_VIEWER_PORT"] || "", 10) || + getRestPort() + 2; + return `http://localhost:${vPort}`; + } +} + +// WebSocket streams port. Engine writes here; the SDK and viewer +// subscribe. Honors both `III_STREAM_PORT` (the singular name the +// engine docs use post-0.11) and `III_STREAMS_PORT` (the name our +// own config.ts has used since 0.7) so a single source of truth in +// either form lights up the ready panel. Falls back to REST+1 so +// `--port 3211` auto-picks 3212 instead of colliding on 3112. +function getStreamPort(): number { + return ( + parseInt(process.env["III_STREAM_PORT"] || "", 10) || + parseInt(process.env["III_STREAMS_PORT"] || "", 10) || + getRestPort() + 1 + ); +} + +// Bridge WebSocket port — the iii engine's internal worker bus. +// Defaults derived from REST as REST+46023 so the canonical 3111 +// anchor yields 49134 and `--port 3211` auto-picks 49234 without a +// second-instance collision. Overridable via +// `III_ENGINE_PORT` or the legacy `III_ENGINE_URL=ws://host:port`. +function getEnginePort(): number { + const explicit = parseInt(process.env["III_ENGINE_PORT"] || "", 10); + if (explicit) return explicit; + const url = process.env["III_ENGINE_URL"]; + if (url) { + try { + const parsed = new URL(url).port; + if (parsed) return parseInt(parsed, 10); + } catch {} + } + return getRestPort() + 46023; +} + +async function isEngineRunning(): Promise { + try { + await fetch(`${getBaseUrl()}/`, { + signal: AbortSignal.timeout(2000), + }); + return true; + } catch { + return false; + } +} + +async function isAgentmemoryReady(): Promise { + try { + const res = await fetch(`${getBaseUrl()}/agentmemory/livez`, { + signal: AbortSignal.timeout(2000), + }); + if (!res.ok) return false; + try { + const data = await res.json() as { viewerPort?: number | null; viewerSkipped?: boolean }; + if (typeof data.viewerPort === "number") { + discoveredViewerPort = data.viewerPort; + return true; + } + if (data.viewerSkipped) return true; + return false; + } catch { + return false; + } + } catch { + return false; + } +} + +function findIiiConfig(): string { + // Precedence (user-overridable wins): explicit env > project cwd > + // ~/.agentmemory/ > bundled. The bundled config used to win + // unconditionally, so users hitting the observability log-feedback + // loop had no way to drop a tamer config in place without + // editing node_modules. + const envPath = process.env["AGENTMEMORY_III_CONFIG"]; + const candidates = [ + ...(envPath ? [envPath] : []), + join(process.cwd(), "iii-config.yaml"), + join(homedir(), ".agentmemory", "iii-config.yaml"), + join(__dirname, "iii-config.yaml"), + join(__dirname, "..", "iii-config.yaml"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return ""; +} + +function whichBinary(name: string): string | null { + const cmd = IS_WINDOWS ? "where" : "which"; + try { + const out = execFileSync(cmd, [name], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + const first = out + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + return first ?? null; + } catch { + return null; + } +} + +// Private install location agentmemory manages itself. Sits under the +// agentmemory state dir (~/.agentmemory/bin) so the pinned engine stays +// isolated from a user-managed iii on PATH or in ~/.local/bin. A +// fresh box with iii 0.16.1 already on PATH refused to boot because the +// hard-pin enforcer told users to overwrite their global install with +// v0.11.2. Private install resolves the conflict without touching their +// existing iii. +function agentmemoryBinDir(): string { + if (IS_WINDOWS) { + const userProfile = process.env["USERPROFILE"]; + if (!userProfile) return join(homedir(), ".agentmemory", "bin"); + return join(userProfile, ".agentmemory", "bin"); + } + return join(homedir(), ".agentmemory", "bin"); +} + +function privateIiiPath(): string { + return join(agentmemoryBinDir(), IS_WINDOWS ? "iii.exe" : "iii"); +} + +function fallbackIiiPaths(): string[] { + if (IS_WINDOWS) { + const userProfile = process.env["USERPROFILE"]; + const paths = [privateIiiPath()]; + if (userProfile) { + paths.push( + join(userProfile, ".local", "bin", "iii.exe"), + join(userProfile, "bin", "iii.exe"), + ); + } + return paths; + } + const home = process.env["HOME"]; + const paths = [privateIiiPath()]; + if (home) { + paths.push(join(home, ".local", "bin", "iii")); + } + paths.push("/usr/local/bin/iii"); + return paths; +} + +function iiiBinVersion(binPath: string): string | null { + try { + const out = execFileSync(binPath, ["--version"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000, + }); + const match = out.match(/(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/); + return match ? match[1]! : null; + } catch { + return null; + } +} + +// Resolve a compatible iii binary for the pinned engine version. +// +// Soft-warn lets the worker boot against a mismatched engine and crash at +// runtime (state::list-not-found on v0.13.0+, sandbox-everything trap on +// v0.11.6+). Hard-pin without a fallback leaves the user stuck — they +// either downgrade their global iii (breaking other consumers) or set +// AGENTMEMORY_III_VERSION and hope it works. +// +// Instead: when the candidate iii on PATH is the wrong version, prefer +// the private install under ~/.agentmemory/bin/iii. If the private copy +// is missing or also mismatched, the caller installs the pinned version +// there before retrying. AGENTMEMORY_III_VERSION still overrides +// IIPINNED_VERSION upstream so users who knowingly want a different +// engine can opt in. +function resolveCompatibleIii(iiiBinPath: string | null | undefined): string | null { + if (!iiiBinPath) return null; + const detected = iiiBinVersion(iiiBinPath); + if (detected && detected === IIPINNED_VERSION) return iiiBinPath; + + const privatePath = privateIiiPath(); + if (iiiBinPath !== privatePath && existsSync(privatePath)) { + const privateVersion = iiiBinVersion(privatePath); + if (privateVersion === IIPINNED_VERSION) { + const reason = detected ? `v${detected} mismatches pin` : "probe failed"; + vlog( + `iii at ${iiiBinPath} ${reason} v${IIPINNED_VERSION}; using private install at ${privatePath}.`, + ); + return privatePath; + } + } + + return null; +} + +function enginePidfilePath(): string { + return join(homedir(), ".agentmemory", "iii.pid"); +} + +function engineStatePath(): string { + return join(homedir(), ".agentmemory", "engine-state.json"); +} + +type EngineState = + | { kind: "native"; configPath: string; attached?: boolean; binPath?: string } + | { kind: "docker"; composeFile: string }; + +function writeEnginePidfile(pid: number): void { + try { + const pidPath = enginePidfilePath(); + mkdirSync(dirname(pidPath), { recursive: true }); + writeFileSync(pidPath, `${pid}\n`, { encoding: "utf-8" }); + } catch (err) { + vlog(`writeEnginePidfile: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function readEnginePidfile(): number | null { + try { + const pidStr = readFileSync(enginePidfilePath(), "utf-8").trim(); + const pid = parseInt(pidStr, 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function clearEnginePidfile(): void { + try { + unlinkSync(enginePidfilePath()); + } catch {} +} + +// Worker pidfile: the agentmemory worker process +// (`node dist/index.mjs`) is spawned by iii-exec inside the engine. When +// `agentmemory stop` kills only the engine pid, the worker can survive +// (detached spawn, signal not propagated, or kept alive by a wrapper +// script). On the next start, the orphaned worker reconnects to the new +// engine and shows up as a duplicate registration. We write the worker +// pid from src/index.ts on boot so stop can find and reap it. +function workerPidfilePath(): string { + return join(homedir(), ".agentmemory", "worker.pid"); +} + +function readWorkerPidfile(): number | null { + try { + const pidStr = readFileSync(workerPidfilePath(), "utf-8").trim(); + const pid = parseInt(pidStr, 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function clearWorkerPidfile(): void { + try { + unlinkSync(workerPidfilePath()); + } catch {} +} + +function writeEngineState(state: EngineState): void { + try { + const statePath = engineStatePath(); + mkdirSync(dirname(statePath), { recursive: true }); + writeFileSync(statePath, `${JSON.stringify(state)}\n`, { encoding: "utf-8" }); + } catch (err) { + vlog(`writeEngineState: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function readEngineState(): EngineState | null { + try { + const raw = readFileSync(engineStatePath(), "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if (parsed && (parsed.kind === "native" || parsed.kind === "docker")) { + return parsed as EngineState; + } + return null; + } catch { + return null; + } +} + +function clearEngineState(): void { + try { + unlinkSync(engineStatePath()); + } catch {} +} + +function discoverComposeFile(): string | null { + const candidates = [ + join(__dirname, "..", "docker-compose.yml"), + join(__dirname, "docker-compose.yml"), + join(process.cwd(), "docker-compose.yml"), + ]; + return candidates.find((c) => existsSync(c)) ?? null; +} + +function isInvokedViaNpx(): boolean { + if (process.env["npm_lifecycle_event"] === "npx") return true; + const argv1 = process.argv[1] ?? ""; + if (argv1.includes("_npx")) return true; + const ua = process.env["npm_config_user_agent"] ?? ""; + if (ua.startsWith("npm/") || ua.includes(" npm/")) return true; + return false; +} + +// First-run global-install prompt. Replaces the previous passive +// `p.log.info` hint that users ignored — typing `agentmemory stop` +// in a new shell would then 404 with `command not found`. We now +// ask once, persist the answer in preferences, and never ask again. +async function maybeOfferGlobalInstall(): Promise { + if (!isInvokedViaNpx()) return; + if (!process.stdin.isTTY) return; + if (process.env["CI"]) return; + const prefs = readPrefs(); + if (prefs.skipGlobalInstall || prefs.skipNpxHint) return; + + const answer = await p.confirm({ + message: + "Install agentmemory globally so the bare `agentmemory` command works in any shell? [Y/n]", + initialValue: true, + }); + if (p.isCancel(answer)) { + // Treat Ctrl+C as "not now" rather than "never". Don't persist. + return; + } + if (answer === false) { + writePrefs({ skipGlobalInstall: true }); + p.log.info( + "Skipped. Re-run via `npx @agentmemory/agentmemory` or install later with: npm install -g @agentmemory/agentmemory", + ); + return; + } + + const npmBin = whichBinary("npm"); + if (!npmBin) { + p.log.warn( + "npm not found on PATH. Install manually: npm install -g @agentmemory/agentmemory", + ); + return; + } + const ok = runCommand( + npmBin, + ["install", "-g", `@agentmemory/agentmemory@${VERSION}`], + { label: `Installing @agentmemory/agentmemory@${VERSION} globally` }, + ); + if (ok) { + p.log.success( + "Installed globally. `agentmemory stop` etc. will now work in new shells.", + ); + // Persist so we never re-prompt even if the user happens to npx + // again from a CI-less TTY. + writePrefs({ skipGlobalInstall: true }); + } else { + p.log.warn( + "Global install failed. Try manually: npm install -g @agentmemory/agentmemory", + ); + } +} + +// iii-console install state. +// "installed" — `iii-console` is on PATH or at `~/.local/bin/iii-console` +// "missing" — binary not found anywhere we look +// We deliberately do NOT probe the console's HTTP port: the binary +// being on disk is the signal we care about (it's not auto-started by +// agentmemory and its default port 3113 collides with our viewer, so +// "is it listening?" is the wrong question at boot time). +type IiiConsoleState = + | { kind: "installed"; binPath: string } + | { kind: "missing" }; + +function detectIiiConsole(): IiiConsoleState { + const onPath = whichBinary("iii-console"); + if (onPath) return { kind: "installed", binPath: onPath }; + const fallback = IS_WINDOWS + ? join(process.env["USERPROFILE"] ?? "", ".local", "bin", "iii-console.exe") + : join(homedir(), ".local", "bin", "iii-console"); + if (fallback && existsSync(fallback)) { + return { kind: "installed", binPath: fallback }; + } + return { kind: "missing" }; +} + +// The upstream install script reads `VERSION` as an env var (see +// install.iii.dev/iii/main/install.sh: `engine_version="${VERSION:-}"`). +// Pin to IIPINNED_VERSION so a fresh boot can never pull a newer iii +// console that talks a different protocol than our pinned engine +// (root cause of protocol drift). +const III_CONSOLE_INSTALL_CMD = + `curl -fsSL https://install.iii.dev/iii/main/install.sh | VERSION=${IIPINNED_VERSION} sh`; + +// Display-only renderer. The internal `runCommand(shBin, ["-c", ...])` +// path uses III_CONSOLE_INSTALL_CMD verbatim (POSIX shell). Anywhere +// that PRINTS the command to a user has to handle Windows separately +// since `VERSION=X sh` and the pipe-to-sh idiom aren't valid in +// cmd.exe / PowerShell. +function iiiConsoleInstallHint(): string { + if (!IS_WINDOWS) return III_CONSOLE_INSTALL_CMD; + return ( + `# PowerShell:\n` + + ` $env:VERSION = "${IIPINNED_VERSION}"\n` + + ` iwr -useb https://install.iii.dev/iii/main/install.sh -OutFile install.sh\n` + + ` bash install.sh # WSL or Git Bash required\n` + + `# Or grab the pinned release directly:\n` + + ` https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}` + ); +} + +async function ensureIiiConsole(): Promise { + const state = detectIiiConsole(); + if (state.kind === "installed") return state; + + // Non-interactive contexts get the panel hint but no prompt. + if (!process.stdin.isTTY || process.env["CI"]) return state; + const prefs = readPrefs(); + if (prefs.skipConsoleInstall) return state; + + const answer = await p.confirm({ + message: + "iii console gives engine-level visibility (workers, functions, queues, traces). Install now?", + initialValue: true, + }); + if (p.isCancel(answer)) return state; + if (answer === false) { + writePrefs({ skipConsoleInstall: true }); + return state; + } + + const shBin = whichBinary("sh"); + const curlBin = whichBinary("curl"); + if (!shBin || !curlBin) { + p.log.warn( + `curl or sh not found. Install manually:\n ${iiiConsoleInstallHint()}`, + ); + return state; + } + const ok = runCommand(shBin, ["-c", III_CONSOLE_INSTALL_CMD], { + label: "Installing iii console", + }); + if (!ok) { + p.log.warn( + `iii console install failed. Re-run manually:\n ${iiiConsoleInstallHint()}`, + ); + return state; + } + // Re-detect rather than trust install-script output paths. + return detectIiiConsole(); +} + +function adoptRunningEngine(): void { + try { + const existingState = readEngineState(); + const existingPid = readEnginePidfile(); + if (existingState && existingPid) return; + + const pids = findEnginePidsByPort(getRestPort()); + const enginePid = pids[0]; + if (enginePid && !existingPid) { + writeEnginePidfile(enginePid); + } + if (!existingState) { + writeEngineState({ + kind: "native", + configPath: findIiiConfig() || "", + attached: true, + }); + } + if (enginePid && !existingPid) { + p.log.info(c.ok(`Attached to existing iii-engine (pid ${enginePid})`)); + } + } catch (err) { + vlog(`adoptRunningEngine: ${err instanceof Error ? err.message : String(err)}`); + } +} + +async function runIiiInstaller(): Promise<{ ok: boolean; binPath: string | null }> { + const releaseUrl = iiiReleaseUrl(); + const asset = iiiReleaseAsset(); + const isZipAsset = asset?.endsWith(".zip") === true; + + if (!releaseUrl) { + p.log.warn( + `iii-engine binary not available for ${platform()}/${process.arch}. Use Docker (\`docker pull iiidev/iii:${IIPINNED_VERSION}\`) or download manually from https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}.`, + ); + return { ok: false, binPath: null }; + } + + if (IS_WINDOWS || isZipAsset) { + p.log.info( + `Auto-install unavailable on ${platform()} — ${asset} isn't tar-compatible. Install manually:\n` + + ` 1. Download ${releaseUrl}\n` + + ` 2. Extract iii.exe and place it on PATH (e.g. %USERPROFILE%\\.local\\bin)\n` + + `Or use Docker: docker pull iiidev/iii:${IIPINNED_VERSION}`, + ); + return { ok: false, binPath: null }; + } + + const shBin = whichBinary("sh"); + const curlBin = whichBinary("curl"); + if (!shBin || !curlBin) { + p.log.warn("curl or sh not found. Cannot auto-install iii-engine."); + return { ok: false, binPath: null }; + } + + const binDir = agentmemoryBinDir(); + const binPath = privateIiiPath(); + const installCmd = [ + `mkdir -p "${binDir}"`, + `curl -fsSL "${releaseUrl}" | tar -xz -C "${binDir}"`, + `chmod +x "${binPath}"`, + ].join(" && "); + const installerOk = runCommand(shBin, ["-c", installCmd], { + label: `Installing iii-engine v${IIPINNED_VERSION} (pinned)`, + optional: true, + }); + if (!installerOk) { + p.log.warn( + `iii-engine installer failed. Fallbacks: Docker (\`docker pull iiidev/iii:${IIPINNED_VERSION}\`) or download manually from https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}.`, + ); + return { ok: false, binPath: null }; + } + return { ok: true, binPath }; +} + +type StartupFailure = { + kind: "no-engine" | "no-docker-compose" | "engine-crashed" | "docker-crashed"; + stderr?: string; + binary?: string; +}; + +let startupFailure: StartupFailure | null = null; + +// Spawn a background engine and collect any startup stderr for a short +// window. The process is unref'd so the CLI parent can exit cleanly; we +// only care about stderr that shows up BEFORE the health check succeeds, +// which is what surfaces early crash/config-parse errors on all platforms. +function spawnEngineBackground( + bin: string, + spawnArgs: string[], + label: string, +): ChildProcess { + vlog(`spawn: ${bin} ${spawnArgs.join(" ")}`); + const child = spawn(bin, spawnArgs, { + detached: true, + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, + }); + const isDocker = label.includes("Docker"); + if (!isDocker && typeof child.pid === "number") { + writeEnginePidfile(child.pid); + } + const stderrChunks: Buffer[] = []; + let stderrBytes = 0; + const MAX_STDERR_CAPTURE = 16 * 1024; + child.stderr?.on("data", (chunk: Buffer) => { + if (stderrBytes >= MAX_STDERR_CAPTURE) return; + const slice = chunk.subarray(0, MAX_STDERR_CAPTURE - stderrBytes); + stderrChunks.push(slice); + stderrBytes += slice.length; + }); + child.on("exit", (code, signal) => { + const abnormal = + (code !== null && code !== 0) || (code === null && signal !== null); + if (abnormal) { + const stderr = Buffer.concat(stderrChunks).toString("utf-8"); + startupFailure = { + kind: isDocker ? "docker-crashed" : "engine-crashed", + stderr: + stderr.trim() || + (signal + ? `process killed by signal ${signal}` + : `process exited with code ${code}`), + binary: bin, + }; + vlog(`engine exited early: code=${code} signal=${signal}`); + if (IS_VERBOSE && stderr.trim()) { + p.log.error(`engine stderr:\n${stderr}`); + } + if (!isDocker) clearEnginePidfile(); + clearEngineState(); + } + }); + child.unref(); + return child; +} + +function startIiiBin(iiiBin: string, configPath: string): boolean { + const s = p.spinner(); + s.start(`Starting iii-engine: ${iiiBin}`); + writeEngineState({ kind: "native", configPath, binPath: iiiBin }); + spawnEngineBackground(iiiBin, ["--config", configPath], "iii-engine"); + s.stop(c.ok("iii-engine process started")); + return true; +} + +// Find a pinned-compatible iii path from a list of candidates. Returns +// the first candidate whose --version matches the pin, OR returns the +// private install path if it exists and matches, OR null if no candidate +// is compatible. Caller (startEngine) auto-installs the pin to the +// private path when this returns null. +function pickCompatibleIii(candidates: Array): string | null { + for (const c of candidates) { + if (!c) continue; + const resolved = resolveCompatibleIii(c); + if (resolved) return resolved; + } + return null; +} + +async function startEngine(): Promise { + const configPath = findIiiConfig(); + const pathIii = whichBinary("iii"); + vlog(`iii binary: ${pathIii ?? "(not on PATH)"}, config: ${configPath || "(not found)"}`); + + const fallbacks = fallbackIiiPaths().filter((p) => existsSync(p)); + for (const f of fallbacks) { + const v = iiiBinVersion(f); + vlog(`fallback iii at ${f} reports version: ${v ?? "unknown"}`); + } + + let iiiBin = pickCompatibleIii([pathIii, ...fallbacks]); + + if (iiiBin && configPath) { + if (iiiBin !== pathIii) { + p.log.info(`Using iii at: ${c.dim(iiiBin)} (v${c.accent(IIPINNED_VERSION)})`); + process.env["PATH"] = `${dirname(iiiBin)}${PATH_DELIMITER}${process.env["PATH"] ?? ""}`; + } + return startIiiBin(iiiBin, configPath); + } + + if (pathIii && !iiiBin) { + const detected = iiiBinVersion(pathIii); + vlog( + `iii on PATH is v${detected ?? "unknown"}, pin is v${IIPINNED_VERSION}. ` + + `Will install pinned engine to ${privateIiiPath()}.`, + ); + } + + if (!configPath) { + startupFailure = { kind: "no-engine" }; + return false; + } + + const dockerBin = whichBinary("docker"); + vlog(`docker binary: ${dockerBin ?? "(not on PATH)"}`); + const dockerComposeCandidates = [ + join(__dirname, "..", "docker-compose.yml"), + join(__dirname, "docker-compose.yml"), + join(process.cwd(), "docker-compose.yml"), + ]; + const composeFile = dockerComposeCandidates.find((c) => existsSync(c)); + vlog(`docker-compose.yml: ${composeFile ?? "(not found)"}`); + + const dockerOptIn = + process.env["AGENTMEMORY_USE_DOCKER"] === "1" || + process.env["AGENTMEMORY_USE_DOCKER"] === "true"; + const interactive = !!process.stdin.isTTY && !process.env["CI"]; + + type Choice = "install" | "docker" | "manual"; + let choice: Choice; + + // Wrong-version iii on PATH is a configuration trap: any prompt would + // confuse the user since they already "have iii installed". Skip the + // prompt and auto-install pinned engine to the private location. + const pathIiiMismatch = pathIii !== null && resolveCompatibleIii(pathIii) === null; + + if (dockerOptIn && dockerBin && composeFile) { + choice = "docker"; + } else if (pathIiiMismatch) { + choice = "install"; + const detected = iiiBinVersion(pathIii!); + p.log.info( + `iii on PATH is v${detected ?? "unknown"} but agentmemory pins v${IIPINNED_VERSION}. ` + + `Installing pinned engine to ~/.agentmemory/bin (leaves your existing iii untouched).`, + ); + } else if (!interactive) { + choice = "install"; + p.log.info("Non-interactive environment detected — auto-installing iii-engine."); + } else { + p.log.warn(`iii-engine binary not found locally.`); + const options: { value: Choice; label: string; hint?: string }[] = [ + { + value: "install", + label: `Install iii v${IIPINNED_VERSION} to ~/.agentmemory/bin (~6MB, ~5s)`, + hint: "recommended", + }, + ]; + if (dockerBin && composeFile) { + options.push({ value: "docker", label: "Use Docker compose", hint: "advanced" }); + } + options.push({ value: "manual", label: "Show manual install steps and exit" }); + + const picked = await p.select({ + message: "How would you like to start iii-engine?", + options, + initialValue: "install", + }); + if (p.isCancel(picked)) { + startupFailure = { kind: "no-engine" }; + return false; + } + choice = picked; + } + + if (choice === "manual") { + startupFailure = { kind: "no-engine" }; + return false; + } + + if (choice === "install") { + const result = await runIiiInstaller(); + if (result.ok && result.binPath) { + process.env["PATH"] = `${dirname(result.binPath)}${PATH_DELIMITER}${process.env["PATH"] ?? ""}`; + iiiBin = result.binPath; + return startIiiBin(iiiBin, configPath); + } + if (dockerBin && composeFile && interactive) { + const fallback = await p.confirm({ + message: "Auto-install failed. Try Docker compose instead?", + initialValue: true, + }); + if (p.isCancel(fallback) || fallback !== true) { + startupFailure = { kind: "no-engine" }; + return false; + } + choice = "docker"; + } else { + startupFailure = { kind: "no-engine" }; + return false; + } + } + + if (choice === "docker" && dockerBin && composeFile) { + const s = p.spinner(); + s.start("Starting iii-engine via Docker..."); + writeEngineState({ kind: "docker", composeFile }); + spawnEngineBackground( + dockerBin, + ["compose", "-f", composeFile, "up", "-d"], + "iii-engine via Docker", + ); + s.stop("Docker compose started"); + return true; + } + + if (!composeFile && dockerBin) { + startupFailure = { kind: "no-docker-compose" }; + } else { + startupFailure = { kind: "no-engine" }; + } + return false; +} + +async function waitForEngine(timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await isEngineRunning()) return true; + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +function installInstructions(): string[] { + const releaseUrl = iiiReleaseUrl(); + if (IS_WINDOWS) { + return [ + `agentmemory needs iii-engine v${IIPINNED_VERSION}. Pick one:`, + "", + " A) Download the prebuilt Windows binary:", + ` 1. Open https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}`, + ` 2. Download iii-x86_64-pc-windows-msvc.zip (or iii-aarch64-pc-windows-msvc.zip on ARM)`, + " 3. Extract iii.exe to %USERPROFILE%\\.local\\bin\\iii.exe (or add to PATH)", + " 4. Re-run: npx @agentmemory/agentmemory", + "", + ` B) Docker: docker pull iiidev/iii:${IIPINNED_VERSION}`, + " Re-run with AGENTMEMORY_USE_DOCKER=1 npx @agentmemory/agentmemory", + "", + "Or skip the engine entirely (standalone MCP): npx @agentmemory/agentmemory mcp", + "", + "Docs: https://iii.dev/docs", + ]; + } + const linuxInstall = releaseUrl + ? ` A) mkdir -p ~/.agentmemory/bin && curl -fsSL "${releaseUrl}" | tar -xz -C ~/.agentmemory/bin && chmod +x ~/.agentmemory/bin/iii` + : ` A) Manual download: https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}`; + return [ + `agentmemory needs iii-engine v${IIPINNED_VERSION}. Pick one:`, + "", + linuxInstall, + " Then re-run: npx @agentmemory/agentmemory", + "", + ` B) Docker: docker pull iiidev/iii:${IIPINNED_VERSION}`, + " Re-run with AGENTMEMORY_USE_DOCKER=1 npx @agentmemory/agentmemory", + "", + "Or skip the engine entirely (standalone MCP): npx @agentmemory/agentmemory mcp", + "", + "Docs: https://iii.dev/docs", + ]; +} + +function portInUseDiagnostic(port: number): string { + return IS_WINDOWS + ? ` netstat -ano | findstr :${port}` + : ` lsof -i :${port} # or: ss -tlnp | grep :${port}`; +} + +async function waitForAgentmemoryReady(timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await isAgentmemoryReady()) return true; + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +// Derive a host string for the streams/engine WebSocket lines from +// the configured engine URL (`III_ENGINE_URL`) or REST base +// (`AGENTMEMORY_URL`) so a remote-bind setup like +// `III_ENGINE_URL=ws://my-host:49134` doesn't print misleading +// localhost addresses. Falls back to localhost. +function getEngineHost(): string { + for (const envKey of ["III_ENGINE_URL", "AGENTMEMORY_URL"]) { + const raw = process.env[envKey]; + if (!raw) continue; + try { + const parsed = new URL(raw); + if (parsed.hostname) return parsed.hostname; + } catch {} + } + return "localhost"; +} + +function printReadyHint(consoleState: IiiConsoleState): void { + // REST goes through getBaseUrl which already honors AGENTMEMORY_URL + // for full host+protocol overrides. Streams/Engine are derived from + // III_ENGINE_URL so a remote bind reads correctly in the panel. + const restUrl = getBaseUrl(); + const viewerUrl = getViewerUrl(); + const engineHost = getEngineHost(); + const streamUrl = `ws://${engineHost}:${getStreamPort()}`; + const engineUrl = `ws://${engineHost}:${getEnginePort()}`; + + const consoleLine = + consoleState.kind === "installed" + ? // We can't safely probe iii-console's port (default 3113 + // collides with our viewer) so we surface the binary location + // and let the user start it on a port of their choice. Use + // the detected binary path so `(run: ...)` is executable as- + // is, even when the binary isn't on PATH under the bare + // name `iii-console`. + `${c.label("iii console")} ${c.dim(consoleState.binPath)} ${c.dim(`(run: ${consoleState.binPath} -p )`)}` + : `${c.label("iii console")} ${c.dim(`(install: ${iiiConsoleInstallHint()})`)}`; + + const lines = [ + `${c.label("REST API")} ${c.url(restUrl)}`, + `${c.label("Viewer")} ${c.url(viewerUrl)}`, + `${c.label("Streams")} ${c.url(streamUrl)}`, + `${c.label("Engine")} ${c.url(engineUrl)}`, + consoleLine, + ]; + // p.note renders a bordered panel with a title — same affordance + // used elsewhere in this CLI for "Troubleshooting" / "Setup + // required" blocks, so the visual language stays consistent. + p.note(lines.join("\n"), `agentmemory v${c.accent(VERSION)}`); + + // Pick a runnable form for the suggested next-step. Users invoked + // via `npx` don't have the bare `agentmemory` command on PATH yet + // (unless they accepted the global-install prompt and the npm bin + // dir was already on PATH in this shell), so we suggest the npx + // form for them; everyone else gets the global form. + const demoCommand = isInvokedViaNpx() + ? "npx @agentmemory/agentmemory demo" + : "agentmemory demo"; + process.stdout.write(`\n${c.dim("Try:")} ${c.cmd(demoCommand)}\n`); +} + +async function main() { + // `--reset` wipes preferences before anything else so the onboarding + // flow below always runs fresh. + if (IS_RESET) { + resetPrefs(); + } + + const firstRun = isFirstRun(); + const prefs = readPrefs(); + // Show the splash on the first run, on --reset, or whenever the user + // hasn't yet opted out via the schema (we set `skipSplash: true` + // after onboarding completes). Verbose runs always splash since the + // user explicitly asked for the chatty experience. + if (firstRun || IS_RESET || IS_VERBOSE || !prefs.skipSplash) { + renderSplash(VERSION); + } + + if (firstRun || IS_RESET) { + await runOnboarding(); + } + + if (skipEngine) { + if (IS_VERBOSE) p.log.info("Skipping engine check (--no-engine)"); + await import("./index.js"); + if (await waitForAgentmemoryReady(15000)) { + const consoleState = await ensureIiiConsole(); + await maybeOfferGlobalInstall(); + printReadyHint(consoleState); + } + return; + } + + if (await isEngineRunning()) { + if (IS_VERBOSE) p.log.success("iii-engine is running"); + // Prefer the binary path persisted at launch time over whatever's on + // PATH now. PATH lookups misfire when a global iii install gets added + // after agentmemory started (or when the running engine was launched + // from a path that's no longer first on PATH). + const persisted = readEngineState(); + const persistedBin = + persisted?.kind === "native" && persisted.binPath && existsSync(persisted.binPath) + ? persisted.binPath + : null; + const attachedBin = + persistedBin ?? + whichBinary("iii") ?? + fallbackIiiPaths().find((p) => existsSync(p)) ?? + null; + const detected = attachedBin ? iiiBinVersion(attachedBin) : null; + + // Fail closed: only adopt the running engine when we can positively + // confirm it is the pinned version. An unknown version (detected === null, + // e.g. the binary can't be version-probed) is treated as incompatible — + // adopting a foreign or unverifiable engine hangs the worker in a + // WebSocket reconnect loop. Worst case here is a re-run that reinstalls + // the pinned engine, never a silent loop. + if (detected === IIPINNED_VERSION) { + adoptRunningEngine(); + await import("./index.js"); + if (await waitForAgentmemoryReady(15000)) { + const consoleState = await ensureIiiConsole(); + await maybeOfferGlobalInstall(); + printReadyHint(consoleState); + } + return; + } + + const detectedLabel = detected ? `v${detected}` : "an unverified version"; + + // An incompatible engine owns the port. Adopting it hangs the worker in a + // WebSocket reconnect loop (it can't speak that engine's protocol), so stop + // with the simplest honest remedy. A normal user has no other engine and + // never sees this; only someone running their own iii does, and for them + // "stop it, then run normally" is the fix. We do not suggest a different + // engine version: agentmemory only supports v${IIPINNED_VERSION}. + const base = isInvokedViaNpx() ? "npx @agentmemory/agentmemory" : "agentmemory"; + p.log.error( + `Another iii-engine (${detectedLabel}) is running on port ${getEnginePort()}, and agentmemory needs its own pinned v${IIPINNED_VERSION}.`, + ); + p.note( + [ + `agentmemory only supports iii-engine v${IIPINNED_VERSION}. It will not adopt or change the running engine (${detectedLabel}).`, + "", + c.label("Switch to the pinned engine in two steps:"), + "", + ` 1. Stop the running engine:`, + ` ${c.cmd(`${base} stop --force`)}`, + ` ${c.dim(`(or stop your own iii however you started it — agentmemory leaves your global iii untouched)`)}`, + "", + ` 2. Start agentmemory. It downloads and runs the pinned`, + ` v${IIPINNED_VERSION} into ~/.agentmemory/bin automatically:`, + ` ${c.cmd(base)}`, + "", + c.dim(`Step 2 needs no manual install. To install iii v${IIPINNED_VERSION} yourself (replaces your global iii), curl:`), + ` ${c.cmd(III_CONSOLE_INSTALL_CMD)}`, + ` ${c.dim("or download the release:")} ${c.url(`https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}`)}`, + ].join("\n"), + "engine conflict", + ); + process.exit(1); + } + + const started = await startEngine(); + if (!started) { + p.log.error("Could not start iii-engine."); + const lines = installInstructions(); + if (startupFailure?.kind === "no-docker-compose") { + lines.unshift( + "Docker is installed but docker-compose.yml is missing from this", + "install. Re-install with: npm install -g @agentmemory/agentmemory", + "", + ); + } + p.note(lines.join("\n"), "Setup required"); + process.exit(1); + } + + const s = p.spinner(); + s.start("Waiting for iii-engine to be ready..."); + + const ready = await waitForEngine(15000); + if (!ready) { + const port = getRestPort(); + s.stop("iii-engine did not become ready within 15s"); + + if (startupFailure?.kind === "engine-crashed" || startupFailure?.kind === "docker-crashed") { + p.log.error("The iii-engine process crashed on startup."); + if (startupFailure.binary) { + p.log.info(`Binary: ${startupFailure.binary}`); + } + if (startupFailure.stderr) { + p.note(startupFailure.stderr, "engine stderr"); + } else { + p.log.info("No stderr was captured. Re-run with --verbose for more detail."); + } + p.note( + [ + "Common causes:", + ` - iii-engine version mismatch — reinstall the pinned v${IIPINNED_VERSION} binary`, + " (sh script on macOS/Linux, GitHub release zip on Windows)", + " - Docker Desktop not running (if you're using the Docker path)", + " - Port already in use (see below)", + "", + "See https://iii.dev/docs for current install instructions.", + ].join("\n"), + "Troubleshooting", + ); + } else { + p.log.error("The engine process started but the REST API never responded."); + p.note( + [ + `Check whether port ${port} is already bound by another process:`, + portInUseDiagnostic(port), + "", + "If it is, free the port or override: agentmemory --port ", + "", + "If it isn't, a firewall may be blocking 127.0.0.1:" + port + ".", + "Re-run with --verbose to see engine stderr.", + ].join("\n"), + "Troubleshooting", + ); + } + process.exit(1); + } + + s.stop(c.ok("iii-engine is ready")); + await import("./index.js"); + if (await waitForAgentmemoryReady(15000)) { + const consoleState = await ensureIiiConsole(); + await maybeOfferGlobalInstall(); + printReadyHint(consoleState); + } + // Mark splash as something to skip on subsequent runs. This is a + // no-op if onboarding already flipped the flag (idempotent merge). + writePrefs({ skipSplash: true }); +} + +async function apiFetch(base: string, path: string, timeoutMs = 5000): Promise { + try { + const headers: Record = {}; + const secret = process.env["AGENTMEMORY_SECRET"]; + if (secret) headers["Authorization"] = `Bearer ${secret}`; + const res = await fetch(`${base}/agentmemory/${path}`, { + signal: AbortSignal.timeout(timeoutMs), + headers, + }); + return (await res.json()) as T; + } catch { + return null; + } +} + +async function runStatus() { + const port = getRestPort(); + const base = getBaseUrl(); + p.intro("agentmemory status"); + + const up = await isEngineRunning(); + if (!up) { + p.log.error(`Not running — no response at ${base}`); + p.log.info("Start with: npx @agentmemory/agentmemory"); + process.exit(1); + } + + try { + const [healthRes, sessionsRes, graphRes, memoriesRes, flagsRes, followupRes] = await Promise.all([ + apiFetch(base, "health"), + apiFetch(base, "sessions"), + apiFetch(base, "graph/stats"), + apiFetch(base, "memories?count=true"), + apiFetch(base, "config/flags"), + apiFetch(base, "diagnostics/followup"), + ]); + + if (typeof healthRes?.viewerPort === "number") { + discoveredViewerPort = healthRes.viewerPort; + } + const h = healthRes?.health; + const status = healthRes?.status || "unknown"; + const version = healthRes?.version || "?"; + const sessionList = Array.isArray(sessionsRes?.sessions) ? sessionsRes.sessions : []; + const sessions = sessionList.length; + const nodes = Number(graphRes?.totalNodes ?? graphRes?.nodes ?? graphRes?.nodeCount ?? 0); + const edges = Number(graphRes?.totalEdges ?? graphRes?.edges ?? graphRes?.edgeCount ?? 0); + const cb = healthRes?.circuitBreaker?.state || "closed"; + const heapMB = h?.memory ? Math.round(h.memory.heapUsed / 1048576) : 0; + const uptime = h?.uptimeSeconds ? Math.round(h.uptimeSeconds) : 0; + + const obsCount = sessionList.reduce( + (sum: number, s: any) => sum + (Number(s?.observationCount) || 0), + 0, + ); + const memCount = Number(memoriesRes?.latestCount ?? memoriesRes?.total ?? 0) || 0; + const estFullTokens = obsCount * 80; + const estInjectedTokens = Math.min(obsCount, 50) * 38; + const tokensSaved = estFullTokens - estInjectedTokens; + const pctSaved = estFullTokens > 0 ? Math.round((tokensSaved / estFullTokens) * 100) : 0; + + p.log.success(`Connected — v${version} at ${base}`); + + const lines = [ + `Health: ${status === "healthy" ? pc.green("✓ healthy") : pc.yellow(status)}`, + `Sessions: ${sessions}`, + `Observations: ${obsCount}`, + `Memories: ${memCount}`, + `Graph: ${nodes} nodes, ${edges} edges`, + `Circuit: ${cb}`, + `Heap: ${heapMB} MB`, + `Uptime: ${uptime}s`, + `Viewer: ${c.url(getViewerUrl())}`, + ]; + + if (obsCount > 0) { + lines.push(""); + lines.push(`Token savings: ~${tokensSaved.toLocaleString()} tokens saved (${pctSaved}% reduction)`); + lines.push(` Full context: ~${estFullTokens.toLocaleString()} tokens`); + lines.push(` Injected: ~${estInjectedTokens.toLocaleString()} tokens`); + } + + if (flagsRes) { + const provider = flagsRes.provider === "llm" ? pc.green("✓ llm") : pc.yellow("✗ noop (no key)"); + const embed = flagsRes.embeddingProvider === "embeddings" ? pc.green("✓ embeddings") : pc.dim("bm25-only"); + const flagRows = (flagsRes.flags || []).map((f: { key: string; enabled: boolean; label: string }) => + ` ${f.enabled ? pc.green("✓") : pc.dim("✗")} ${pc.bold(f.key.padEnd(32))} ${f.label}` + ); + lines.push(""); + lines.push(`Provider: ${provider}`); + lines.push(`Embeddings: ${embed}`); + lines.push(`Flags:`); + flagRows.forEach((r: string) => lines.push(r)); + } + + if (followupRes && Number.isFinite(followupRes.agentInitiatedSearches)) { + const total = Number(followupRes.agentInitiatedSearches) || 0; + const hits = Number(followupRes.followupWithinWindow) || 0; + const pct = total > 0 ? Math.round((hits / total) * 100) : 0; + lines.push(""); + lines.push( + `Followup rate: ${hits}/${total} (${pct}%) within ${followupRes.windowSeconds}s — directional, may overcount on refinement`, + ); + } + + p.note(lines.join("\n"), "agentmemory"); + } catch (err) { + p.log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +} + +type DoctorCheck = { name: string; ok: boolean; hint?: string }; + +function formatChecks(checks: DoctorCheck[]): string { + return checks + .map((c) => `${c.ok ? pc.green("✓") : pc.red("✗")} ${c.name}${c.hint ? `\n ${c.hint}` : ""}`) + .join("\n"); +} + +type CCHooksCheck = + | { state: "loaded"; manifestPath?: string } + | { state: "not-loaded" } + | { state: "no-debug-log" } + | { state: "no-cc-dir" }; + +function findLatestDebugLog(debugDir: string): string | undefined { + const latestLink = join(debugDir, "latest"); + try { + if (existsSync(latestLink)) { + const target = readlinkSync(latestLink); + const resolved = target.startsWith("/") ? target : join(debugDir, target); + if (existsSync(resolved)) return resolved; + } + } catch {} + + try { + const newest = readdirSync(debugDir) + .filter((f) => f.endsWith(".txt")) + .map((f) => ({ f, m: statSync(join(debugDir, f)).mtimeMs })) + .sort((a, b) => b.m - a.m)[0]; + if (newest) return join(debugDir, newest.f); + } catch {} + + return undefined; +} + +function checkClaudeCodeHooks(): CCHooksCheck { + const debugDir = join(homedir(), ".claude", "debug"); + if (!existsSync(debugDir)) return { state: "no-cc-dir" }; + + const logPath = findLatestDebugLog(debugDir); + if (!logPath) return { state: "no-debug-log" }; + + let content: string; + try { + content = readFileSync(logPath, "utf8"); + } catch { + return { state: "no-debug-log" }; + } + + const match = content.match( + /Loaded hooks from standard location for plugin agentmemory:\s*(\S+)/ + ); + if (match) return { state: "loaded", manifestPath: match[1] }; + if (content.includes("Loading hooks from plugin: agentmemory")) return { state: "loaded" }; + return { state: "not-loaded" }; +} + +// --------------------------------------------------------------------------- +// Doctor v2 — interactive fixer. +// +// The legacy passive check-list (server reachable, flags, knowledge-graph, +// Claude Code hooks) still runs first as an informational summary because +// those checks need a live engine and don't have a one-shot inline fix. +// Then we drive the new diagnostic catalog (see src/cli/doctor-diagnostics.ts) +// which prompts Fix/Skip/More/Quit per failing check, applies the fix +// inline, and re-checks only the affected diagnostic. + +function buildDoctorContext(): DoctorContext { + return { + baseUrl: getBaseUrl(), + viewerUrl: getViewerUrl(), + envPath: join(homedir(), ".agentmemory", ".env"), + pidfilePath: enginePidfilePath(), + enginePath: engineStatePath(), + pinnedVersion: IIPINNED_VERSION, + }; +} + +function buildDoctorEffects(): DoctorEffects { + return { + envFileExists: () => existsSync(join(homedir(), ".agentmemory", ".env")), + readEnvFile: () => { + try { + return parseEnvFile( + readFileSync(join(homedir(), ".agentmemory", ".env"), "utf-8"), + ); + } catch { + return {}; + } + }, + pidfileExists: () => existsSync(enginePidfilePath()), + pidfilePidIsAlive: () => { + const pid = readEnginePidfile(); + if (pid === null) return null; + return pidAlive(pid); + }, + findIiiBinary: () => whichBinary("iii"), + localBinIiiPath: () => privateIiiPath(), + iiiBinaryVersion: (binPath: string) => iiiBinVersion(binPath), + viewerReachable: async (timeoutMs = 2000) => { + try { + await discoverViewerPort(); + const res = await fetch(getViewerUrl(), { + signal: AbortSignal.timeout(timeoutMs), + }); + return res.ok; + } catch { + return false; + } + }, + runInit: async () => { + try { + await runInit(); + return { ok: true, message: "Wrote ~/.agentmemory/.env" }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : String(err), + }; + } + }, + openEditor: async (path: string) => { + const editor = process.env["EDITOR"] || process.env["VISUAL"] || "nano"; + p.log.info(`Opening ${path} in ${editor}…`); + try { + // Inherit stdio so the user actually sees the editor. + const result = spawnSync(editor, [path], { stdio: "inherit" }); + if (result.error) { + return { + ok: false, + message: `Failed to launch ${editor}: ${result.error.message}`, + }; + } + if ((result.status ?? 0) !== 0) { + return { + ok: false, + message: `${editor} exited with code ${result.status}`, + }; + } + return { ok: true, message: `Saved ${path}` }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : String(err), + }; + } + }, + runIiiInstaller: async () => { + const r = await runIiiInstaller(); + return { + ok: r.ok, + message: r.ok + ? `Installed iii v${IIPINNED_VERSION} to ${r.binPath}` + : "iii installer failed (see warnings above)", + }; + }, + runStop: async () => { + try { + // runStop calls process.exit on its own — guard against that here + // by short-circuiting when there's nothing to stop. + const port = getRestPort(); + const portPids = findEnginePidsByPort(port); + const pidfilePid = readEnginePidfile(); + if (portPids.length === 0 && pidfilePid === null) { + clearEnginePidfile(); + clearEngineState(); + return { ok: true, message: "Nothing to stop." }; + } + const candidates = new Set(); + if (pidfilePid) candidates.add(pidfilePid); + for (const pid of portPids) candidates.add(pid); + let allStopped = true; + for (const pid of candidates) { + const ok = await signalAndWait(pid, "SIGTERM", 3000); + if (!ok) allStopped = false; + } + clearEnginePidfile(); + clearEngineState(); + return { + ok: allStopped, + message: allStopped ? "Engine stopped." : "Some engine pids survived.", + }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : String(err), + }; + } + }, + runStart: async () => { + try { + const started = await startEngine(); + if (!started) return { ok: false, message: "startEngine() returned false" }; + const ready = await waitForEngine(15000); + return { + ok: ready, + message: ready ? "Engine ready" : "Engine did not become ready within 15s", + }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : String(err), + }; + } + }, + clearEnginePidAndState: () => { + clearEnginePidfile(); + clearEngineState(); + }, + }; +} + +async function passiveServerChecks(): Promise { + const base = getBaseUrl(); + const checks: DoctorCheck[] = []; + + const serverUp = await isEngineRunning(); + checks.push({ + name: "Server reachable", + ok: serverUp, + hint: serverUp + ? undefined + : `Start with: npx @agentmemory/agentmemory (tried ${base})`, + }); + if (!serverUp) return checks; + + const [health, flags, graph] = await Promise.all([ + apiFetch(base, "health", 3000), + apiFetch(base, "config/flags", 3000), + apiFetch(base, "graph/stats", 3000), + ]); + + const hasLlm = flags?.provider === "llm"; + const hasEmbed = flags?.embeddingProvider === "embeddings"; + const graphNodeCount = Number( + graph?.totalNodes ?? graph?.nodes ?? graph?.nodeCount ?? 0, + ); + const graphHas = graphNodeCount > 0; + + checks.push( + { + name: "Health status", + ok: health?.status === "healthy", + hint: + health?.status === "healthy" + ? undefined + : `Status: ${health?.status || "unknown"}`, + }, + { + name: "LLM provider", + ok: hasLlm, + hint: hasLlm ? undefined : "set ANTHROPIC_API_KEY (or GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env", + }, + { + name: "Embedding provider", + ok: hasEmbed, + hint: hasEmbed + ? undefined + : "Running BM25-only. Add OPENAI_API_KEY / VOYAGE_API_KEY / COHERE_API_KEY / OLLAMA_HOST", + }, + ); + + for (const f of (flags?.flags || []) as { + label: string; + enabled: boolean; + enableHow: string; + }[]) { + checks.push({ + name: f.label, + ok: f.enabled, + hint: f.enabled ? undefined : f.enableHow, + }); + } + + const cc = checkClaudeCodeHooks(); + const ccCheck = (() => { + switch (cc.state) { + case "loaded": + return { + ok: true, + hint: cc.manifestPath ? `manifest: ${cc.manifestPath}` : undefined, + }; + case "not-loaded": + return { + ok: false, + hint: + "Plugin enabled but hooks not loaded by Claude Code. Try: /plugin uninstall agentmemory@agentmemory && /plugin install agentmemory@agentmemory, then restart the session.", + }; + case "no-debug-log": + return { + ok: false, + hint: + 'Cannot verify — no Claude Code debug log found. Run once with `claude --debug -p "x"`, then re-run doctor.', + }; + case "no-cc-dir": + return undefined; + } + })(); + if (ccCheck) checks.push({ name: "Claude Code plugin hooks registered", ...ccCheck }); + + checks.push({ + name: "Knowledge graph populated", + ok: graphHas, + hint: graphHas + ? undefined + : "Graph is empty. Run a session with GRAPH_EXTRACTION_ENABLED=true.", + }); + + return checks; +} + +type DoctorAction = "fix" | "skip" | "more" | "quit"; + +async function askFixAction(d: Diagnostic): Promise { + const choice = await p.select({ + message: `[${d.id}] ${d.message}`, + options: [ + { value: "fix", label: "F Fix", hint: d.fixPreview }, + { value: "skip", label: "S Skip" }, + { value: "more", label: "? More info" }, + { value: "quit", label: "Q Quit doctor" }, + ], + initialValue: "fix", + }); + if (p.isCancel(choice)) return "quit"; + return choice; +} + +async function applyFixWithReport( + d: Diagnostic, + ctx: DoctorContext, + dryRun: boolean, +): Promise { + if (dryRun) { + p.log.info(`[dry-run] would: ${d.fixPreview}`); + return { ok: true, message: "(dry-run)" }; + } + const result = await d.fix(ctx); + if (result.ok) { + p.log.success(result.message ?? `${d.id} fixed.`); + } else { + p.log.error(result.message ?? `${d.id} fix failed.`); + } + return result; +} + +async function runDoctor() { + p.intro("agentmemory doctor"); + const applyAll = args.includes("--all"); + const dryRun = args.includes("--dry-run"); + if (applyAll && dryRun) { + p.log.error("Cannot combine --all and --dry-run."); + process.exit(2); + } + + // Passive server checks (informational). + const passive = await passiveServerChecks(); + const passivePassed = passive.filter((c) => c.ok).length; + p.note(formatChecks(passive), `server: ${passivePassed}/${passive.length} passing`); + + // Doctor v2 interactive catalog. + const ctx = buildDoctorContext(); + const effects = buildDoctorEffects(); + const diagnostics = buildDiagnostics(effects); + + if (dryRun) { + const results: Array<{ diagnostic: Diagnostic; status: { ok: boolean; detail?: string } }> = []; + for (const d of diagnostics) results.push({ diagnostic: d, status: await d.check(ctx) }); + const lines = dryRunPlan(ctx, results); + p.note(lines.join("\n"), "dry-run plan"); + p.outro("Dry-run complete. Re-run without --dry-run to apply."); + return; + } + + let failed = 0; + let fixed = 0; + let skipped = 0; + let quit = false; + + for (const d of diagnostics) { + if (quit) { + skipped++; + continue; + } + const status = await d.check(ctx); + if (status.ok) { + p.log.success(`${d.id} ✓${status.detail ? ` (${status.detail})` : ""}`); + continue; + } + failed++; + p.log.warn(`${d.id} ✗ ${status.detail ?? ""}`.trim()); + p.log.info(`why: ${d.fixPreview}`); + + if (d.manualOnly) { + p.log.info(`(manual fix only — see "${d.id}" docs)`); + } + + if (applyAll) { + const r = await applyFixWithReport(d, ctx, false); + if (r.ok) fixed++; + // Re-check only this diagnostic. + const after = await d.check(ctx); + if (!after.ok) p.log.warn(`${d.id} still failing after fix.`); + continue; + } + + // Interactive prompt loop — allow [?] More info without leaving the check. + while (true) { + const action = await askFixAction(d); + if (action === "fix") { + const r = await applyFixWithReport(d, ctx, false); + if (r.ok) { + const after = await d.check(ctx); + if (after.ok) { + fixed++; + } else { + p.log.warn(`${d.id} still failing after fix: ${after.detail ?? ""}`); + } + } + break; + } + if (action === "skip") { + skipped++; + break; + } + if (action === "more") { + p.note(d.moreInfo, `[${d.id}] more info`); + continue; + } + if (action === "quit") { + quit = true; + break; + } + } + } + + const summary = `${diagnostics.length} checks · ${failed} failing · ${fixed} fixed · ${skipped} skipped`; + if (quit) { + p.outro(`Quit early. ${summary}`); + process.exit(1); + } + if (failed === 0) { + p.outro("All diagnostics passing. agentmemory is healthy."); + return; + } + if (failed - fixed === 0) { + p.outro(`All fixes applied. ${summary}`); + return; + } + p.outro(summary); + process.exit(1); +} + +type DemoObservation = { + toolName: string; + toolInput: Record; + toolOutput: string; +}; + +type DemoSession = { + id: string; + title: string; + observations: DemoObservation[]; +}; + +type SearchResult = { query: string; hits: number; topTitle: string }; + +function buildDemoSessions(): DemoSession[] { + return [ + { + id: generateId("demo"), + title: "Session 1: JWT auth setup", + observations: [ + { + toolName: "Write", + toolInput: { file_path: "src/middleware/auth.ts" }, + toolOutput: + "Created JWT middleware using jose library. Tokens expire after 30 days. Chose jose over jsonwebtoken for Edge compatibility.", + }, + { + toolName: "Write", + toolInput: { file_path: "test/auth.test.ts" }, + toolOutput: + "Added token validation tests covering expired, malformed, and valid cases.", + }, + { + toolName: "Bash", + toolInput: { command: "npm test" }, + toolOutput: "All 12 auth tests passing.", + }, + ], + }, + { + id: generateId("demo"), + title: "Session 2: Database migration debugging", + observations: [ + { + toolName: "Read", + toolInput: { file_path: "prisma/schema.prisma" }, + toolOutput: + "Found N+1 query issue in user relations. Need to add include on posts query.", + }, + { + toolName: "Edit", + toolInput: { file_path: "src/api/users.ts" }, + toolOutput: + "Fixed N+1 by adding Prisma include. Query time dropped from 450ms to 28ms.", + }, + ], + }, + { + id: generateId("demo"), + title: "Session 3: Rate limiting", + observations: [ + { + toolName: "Write", + toolInput: { file_path: "src/middleware/ratelimit.ts" }, + toolOutput: + "Added rate limiting middleware with 100 req/min default. Uses in-memory store for dev, Redis for prod.", + }, + ], + }, + ]; +} + +async function postJson( + url: string, + body: unknown, + timeoutMs = 5000, +): Promise { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) return null; + return (await res.json().catch(() => null)) as T | null; + } catch { + return null; + } +} + +async function postJsonStrict( + url: string, + body: unknown, + timeoutMs = 5000, +): Promise { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + const suffix = errBody ? ` — ${errBody.slice(0, 200)}` : ""; + throw new Error(`POST ${url} failed: ${res.status} ${res.statusText}${suffix}`); + } + return (await res.json().catch(() => null)) as T | null; +} + +async function seedDemoSession( + base: string, + project: string, + session: DemoSession, +): Promise { + await postJsonStrict(`${base}/agentmemory/session/start`, { + sessionId: session.id, + project, + cwd: project, + }); + + let stored = 0; + for (const obs of session.observations) { + const url = `${base}/agentmemory/observe`; + const payload = { + hookType: "post_tool_use", + sessionId: session.id, + project, + cwd: project, + timestamp: new Date().toISOString(), + data: { + tool_name: obs.toolName, + tool_input: obs.toolInput, + tool_output: obs.toolOutput, + }, + }; + + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000), + }); + if (res.ok) { + stored++; + } else { + const body = await res.text().catch(() => ""); + p.log.warn( + `observe failed for ${obs.toolName}: ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 160)}` : ""}`, + ); + } + } catch (err) { + p.log.warn( + `observe request failed for ${obs.toolName}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + await postJsonStrict(`${base}/agentmemory/session/end`, { sessionId: session.id }); + return stored; +} + +async function runDemoSearch(base: string, query: string): Promise { + const data = await postJson<{ results?: Array<{ title?: string }> }>( + `${base}/agentmemory/smart-search`, + { query, limit: 5 }, + 10000, + ); + const items = data?.results ?? []; + return { + query, + hits: items.length, + topTitle: items[0]?.title ?? "(no results)", + }; +} + +// Prefer the packaged `.env.example` (next to `dist/cli.mjs`); fall back to +// the repo root when running from a source checkout. +function findEnvExample(): string | null { + const candidates = [ + join(__dirname, "..", ".env.example"), + join(__dirname, ".env.example"), + join(process.cwd(), ".env.example"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +async function runInit() { + p.intro("agentmemory init"); + const target = join(homedir(), ".agentmemory", ".env"); + const template = findEnvExample(); + if (!template) { + p.log.error( + "Could not locate .env.example in the package. Re-install with: npm i -g @agentmemory/agentmemory", + ); + process.exit(1); + } + const dir = dirname(target); + const { mkdir, copyFile } = await import("node:fs/promises"); + const { constants: fsConstants } = await import("node:fs"); + try { + await mkdir(dir, { recursive: true }); + // COPYFILE_EXCL collapses the exists-check + copy into one syscall — + // an existsSync(target) + copyFile() pair races with a parallel init + // (or any other process touching ~/.agentmemory/.env between the two + // calls) and would silently overwrite a config the operator just + // wrote. EEXIST out of copyFile is the only "already configured" + // signal we trust. + await copyFile(template, target, fsConstants.COPYFILE_EXCL); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "EEXIST") { + p.log.warn(`${target} already exists — leaving it untouched.`); + p.log.info( + `Compare against the latest template: diff ${target} ${template}`, + ); + p.outro("Nothing changed."); + return; + } + p.log.error( + `Failed to copy template: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + p.log.success(`Wrote ${target}`); + p.note( + [ + "All keys are commented out by default. Uncomment the ones you want.", + "", + "Common next steps:", + " 1. Pick an LLM provider key (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / etc.)", + " 2. Run `npx @agentmemory/agentmemory doctor` to verify the daemon sees them", + " 3. Run `npx @agentmemory/agentmemory` to start the worker", + ].join("\n"), + "Next steps", + ); + p.outro(`Edit ${target} and you're set.`); +} + +async function startServerForDemo(): Promise<() => Promise> { + if (await isAgentmemoryReady()) { + return async () => {}; + } + + const startedEngine = !(await isEngineRunning()); + if (startedEngine) { + const ok = await startEngine(); + if (!ok) { + p.log.error("Could not start iii-engine for the demo."); + p.note(installInstructions().join("\n"), "Setup required"); + process.exit(1); + } + if (!(await waitForEngine(15000))) { + p.log.error("iii-engine did not become ready within 15s."); + process.exit(1); + } + } + + await import("./index.js"); + if (!(await waitForAgentmemoryReady(15000))) { + p.log.error("agentmemory worker did not become ready within 15s."); + process.exit(1); + } + + return async () => { + if (!startedEngine) return; + const port = getRestPort(); + const state = readEngineState(); + if (state?.kind === "docker") { + await stopDockerEngine(state.composeFile, port).catch(() => {}); + return; + } + const pids = new Set(findEnginePidsByPort(port)); + const pidfilePid = readEnginePidfile(); + if (pidfilePid) pids.add(pidfilePid); + for (const pid of pids) { + await signalAndWait(pid, "SIGTERM", 3000).catch(() => {}); + } + clearEnginePidfile(); + clearEngineState(); + clearWorkerPidfile(); + }; +} + +async function runDemo() { + const port = getRestPort(); + const base = `http://localhost:${port}`; + p.intro("agentmemory demo"); + + const serve = args.includes("--serve"); + let teardown: () => Promise = async () => {}; + + if (serve) { + teardown = await startServerForDemo(); + } else if (!(await isAgentmemoryReady())) { + p.log.error( + `agentmemory worker not reachable on port ${port} (livez probe failed). Something may be on the port but it isn't serving /agentmemory/*.`, + ); + p.log.info("Start it with: npx @agentmemory/agentmemory"); + p.log.info("Or run a one-command demo with: npx @agentmemory/agentmemory demo --serve"); + process.exit(1); + } + + try { + await runDemoBody(base); + } finally { + await teardown(); + } + + if (serve) { + process.exit(0); + } +} + +async function runDemoBody(base: string) { + const demoProject = "/tmp/agentmemory-demo"; + const sessions = buildDemoSessions(); + + const sSeed = p.spinner(); + sSeed.start("Seeding 3 demo sessions with realistic observations..."); + + let totalObs = 0; + for (const session of sessions) { + totalObs += await seedDemoSession(base, demoProject, session); + } + + sSeed.stop(`Seeded ${totalObs} observations across ${sessions.length} sessions`); + + const queries = [ + "jwt auth middleware", + "database performance optimization", + "rate limiting", + ]; + + const sQuery = p.spinner(); + sQuery.start(`Running ${queries.length} smart-search queries...`); + + const results: SearchResult[] = []; + for (const query of queries) { + results.push(await runDemoSearch(base, query)); + } + + sQuery.stop("Search complete"); + + const lines = [ + `Project: ${demoProject}`, + `Sessions: ${sessions.length} seeded (${totalObs} observations)`, + "", + c.label("Search results:"), + ...results.flatMap((r) => [ + ` ${c.label(`"${r.query}"`)}`, + ` ${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.`), + "", + `Viewer: ${c.url(getViewerUrl())}`, + `Clean up with: ${c.dim(`curl -X DELETE "${base}/agentmemory/sessions?project=${demoProject}"`)}`, + ]; + + p.note(lines.join("\n"), "demo complete"); + p.log.success("agentmemory is working. Point your agent at it and get back to coding."); +} + +function runCommand( + command: string, + commandArgs: string[], + options: { cwd?: string; label: string; optional?: boolean } = { label: "command" }, +): boolean { + const spinner = p.spinner(); + spinner.start(options.label); + const result = spawnSync(command, commandArgs, { + cwd: options.cwd || process.cwd(), + stdio: "pipe", + encoding: "utf-8", + }); + + if (result.status === 0) { + spinner.stop(`${options.label} ${pc.green("✓")}`); + return true; + } + + const stderr = (result.stderr || "").toString().trim(); + const stdout = (result.stdout || "").toString().trim(); + const msg = stderr || stdout || "unknown error"; + + if (options.optional) { + spinner.stop(`${options.label} (skipped)`); + p.log.warn(msg.slice(0, 300)); + return false; + } + + spinner.stop(`${options.label} ${pc.red("✗")}`); + p.log.error(msg.slice(0, 300)); + return false; +} + +async function runUpgrade() { + p.intro("agentmemory upgrade"); + + const cwd = process.cwd(); + const hasPackageJson = existsSync(join(cwd, "package.json")); + const hasPnpmLock = existsSync(join(cwd, "pnpm-lock.yaml")); + + const pnpmBin = whichBinary("pnpm"); + const npmBin = whichBinary("npm"); + const dockerBin = whichBinary("docker"); + + p.log.info(`Working directory: ${cwd}`); + const requireSuccess = (ok: boolean, label: string): void => { + if (!ok) { + p.log.error(`Upgrade aborted: ${label} failed.`); + process.exit(1); + } + }; + + if (hasPackageJson) { + const usePnpm = !!pnpmBin && hasPnpmLock; + if (usePnpm && pnpmBin) { + const installOk = runCommand(pnpmBin, ["install"], { + label: "Refreshing dependencies (pnpm install)", + }); + requireSuccess(installOk, "pnpm install"); + runCommand(pnpmBin, ["up", "iii-sdk@0.11.2"], { + label: "Pinning iii-sdk@0.11.2", + optional: true, + }); + } else if (npmBin) { + const installOk = runCommand(npmBin, ["install"], { + label: "Refreshing dependencies (npm install)", + }); + requireSuccess(installOk, "npm install"); + runCommand(npmBin, ["install", "iii-sdk@0.11.2"], { + label: "Pinning iii-sdk@0.11.2", + optional: true, + }); + } else { + p.log.warn("No package manager found (pnpm/npm). Skipping JS dependency upgrade."); + } + } else { + p.log.warn("No package.json in current directory. Skipping JS dependency upgrade."); + } + + const upgradeEngine = await p.confirm({ + message: "Re-run the iii-engine install script (curl | sh)?", + initialValue: true, + }); + if (p.isCancel(upgradeEngine)) { + p.cancel("Cancelled."); + return process.exit(0); + } + if (upgradeEngine === true) { + await runIiiInstaller(); + } else { + p.log.info("Skipped iii-engine installer."); + } + + if (dockerBin) { + runCommand(dockerBin, ["pull", `iiidev/iii:${IIPINNED_VERSION}`], { + label: `Pulling iii Docker image v${IIPINNED_VERSION} (pinned)`, + optional: true, + }); + } else { + p.log.info("Docker not found. Skipping Docker image refresh."); + } + + p.note( + [ + "Upgrade flow completed.", + "", + "Recommended next steps:", + " 1) agentmemory status", + " 2) npm/pnpm test", + " 3) restart agentmemory process", + ].join("\n"), + "agentmemory upgrade", + ); +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException)?.code === "EPERM"; + } +} + +async function signalAndWait( + pid: number, + initialSignal: NodeJS.Signals, + timeoutMs: number, +): Promise { + try { + process.kill(pid, initialSignal); + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === "ESRCH") return true; + if (code === "EPERM") { + p.log.warn(`No permission to signal pid ${pid}. Try: kill ${pid}`); + return false; + } + vlog(`${initialSignal} ${pid}: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!pidAlive(pid)) return true; + await new Promise((r) => setTimeout(r, 200)); + } + if (!pidAlive(pid)) return true; + try { + process.kill(pid, "SIGKILL"); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ESRCH") return true; + vlog(`SIGKILL ${pid}: ${err instanceof Error ? err.message : String(err)}`); + return false; + } + await new Promise((r) => setTimeout(r, 200)); + return !pidAlive(pid); +} + +function findEnginePidsByPort(port: number): number[] { + if (IS_WINDOWS) return []; + const lsof = whichBinary("lsof"); + if (!lsof) return []; + // -sTCP:LISTEN restricts to listening server sockets only. Without + // this, lsof also returns client-side PIDs (any process with an + // active TCP connection to :port), which includes the agentmemory + // CLI itself thanks to the keep-alive fetch in isEngineRunning(). + // signalAndWait would then SIGKILL its own parent — exit code 137. + const selfPid = process.pid; + try { + const out = execFileSync(lsof, ["-i", `:${port}`, "-sTCP:LISTEN", "-t"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + return out + .split(/\s+/) + .map((s) => parseInt(s, 10)) + .filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid); + } catch (err) { + vlog(`lsof :${port}: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +async function stopDockerEngine(composeFile: string, port: number): Promise { + const dockerBin = whichBinary("docker"); + if (!dockerBin) { + p.log.error( + `Engine was started via Docker compose, but \`docker\` is no longer on PATH. Stop it manually:\n docker compose -f ${composeFile} down`, + ); + process.exit(1); + } + if (!existsSync(composeFile)) { + p.log.error( + `Engine state references ${composeFile}, but the file is gone. Stop it manually:\n docker compose down (from the dir holding the original docker-compose.yml)`, + ); + process.exit(1); + } + const ok = runCommand(dockerBin, ["compose", "-f", composeFile, "down"], { + label: `docker compose -f ${composeFile} down`, + }); + clearEnginePidfile(); + clearEngineState(); + clearWorkerPidfile(); + if (!ok) { + p.log.error( + `docker compose down failed. The engine may still be running on :${port}. Inspect with:\n docker compose -f ${composeFile} ps`, + ); + process.exit(1); + } + p.outro("Stopped. Memories persisted to disk; restart anytime with: npx @agentmemory/agentmemory"); +} + +async function runStop(): Promise { + p.intro("agentmemory stop"); + const port = getRestPort(); + const state = readEngineState(); + const running = await isEngineRunning(); + const force = args.includes("--force"); + + if (state?.kind === "docker") { + if (!running) { + p.log.info(`No engine responding on port ${port}.`); + clearEnginePidfile(); + clearEngineState(); + clearWorkerPidfile(); + p.outro("Nothing to stop."); + return; + } + await stopDockerEngine(state.composeFile, port); + return; + } + + const portPids = findEnginePidsByPort(port); + const pidfilePid = readEnginePidfile(); + // read the worker pid up front so the engine-down branch + // can still reap an orphaned worker process (the common failure mode + // where a wrapper script kept the worker alive across engine restarts). + const workerPid = readWorkerPidfile(); + + if (!running) { + if (portPids.length === 0 && pidfilePid === null && workerPid === null) { + clearEnginePidfile(); + clearEngineState(); + clearWorkerPidfile(); + p.outro("Nothing to stop."); + return; + } + if (workerPid !== null && portPids.length === 0 && pidfilePid === null) { + // Engine already gone but worker is lingering — reap it directly + // instead of preserving for manual cleanup. + const s = p.spinner(); + s.start(`Stopping orphaned agentmemory worker (pid ${workerPid})...`); + const ok = await signalAndWait(workerPid, "SIGTERM", 3000); + s.stop(ok ? `Stopped worker pid ${workerPid}` : `Failed to stop worker pid ${workerPid}`); + clearEnginePidfile(); + clearEngineState(); + clearWorkerPidfile(); + if (!ok) { + p.log.error(`Worker pid ${workerPid} survived SIGKILL. Investigate with \`ps\`.`); + process.exit(1); + } + p.outro("Stopped orphaned worker. Memories persisted to disk."); + return; + } + const survivors = new Set(portPids); + if (pidfilePid) survivors.add(pidfilePid); + if (workerPid) survivors.add(workerPid); + p.log.warn( + `Engine not responding on :${port}, but ${survivors.size} process(es) still hold the port or pidfile: ${[...survivors].join(", ")}`, + ); + p.log.info( + `Preserving ~/.agentmemory/iii.pid + worker.pid. Investigate before manual cleanup:\n ps -p ${[...survivors].join(",")} -o pid,ppid,comm,etime\n ${IS_WINDOWS ? "netstat -ano | findstr :" + port : "lsof -i :" + port}`, + ); + process.exit(1); + } + + if (!state) { + const compose = discoverComposeFile(); + if (compose && pidfilePid === null) { + if (force) { + p.log.warn( + `--force: bypassing Docker-heuristic guard. Falling back to native pidfile + lsof on :${port}.`, + ); + } else { + p.log.error( + `Engine is running on :${port} but no pidfile or state file is present. It may have been started via Docker compose by a different shell. Refusing to signal host PIDs.\n\nStop it with:\n docker compose -f ${compose} down\n\nOr re-run with --force to signal whatever lsof finds on :${port}, or AGENTMEMORY_USE_DOCKER=1 to record state next time.`, + ); + process.exit(1); + } + } + } + + const candidates = new Set(); + if (pidfilePid) candidates.add(pidfilePid); + for (const pid of portPids) candidates.add(pid); + + // stop must also reap the agentmemory worker process + // (`node dist/index.mjs`). If only the engine is killed, the worker can + // survive (detached spawn / signal not propagated) and reconnect to the + // next engine as a duplicate registration. workerPid was read above so + // the engine-down branch could also reap orphans. + const workerCandidates = new Set(); + if (workerPid) workerCandidates.add(workerPid); + + if (candidates.size === 0 && workerCandidates.size === 0) { + p.log.error( + `Could not locate engine process. Try:\n ${IS_WINDOWS ? "netstat -ano | findstr :" + port : "lsof -i :" + port + " -t | xargs kill -9"}`, + ); + process.exit(1); + } + + let allStopped = true; + // stop worker first, then engine. The worker's shutdown + // handler calls indexPersistence.save() -> kv.set() -> iii state::set + // to flush BM25/vector snapshots + audit rows. Killing iii first + // leaves those writes with no engine to land on, and the index + + // observations end up as in-memory state the iii process never + // 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; + } + for (const pid of candidates) { + if (workerCandidates.has(pid)) continue; + const s = p.spinner(); + s.start(`Stopping iii-engine (pid ${pid})...`); + const ok = await signalAndWait(pid, "SIGTERM", 3000); + s.stop(ok ? `Stopped pid ${pid}` : `Failed to stop pid ${pid}`); + if (!ok) allStopped = false; + } + + clearEnginePidfile(); + clearEngineState(); + clearWorkerPidfile(); + if (!allStopped) { + p.log.error("One or more processes survived SIGKILL. Investigate with `ps`."); + process.exit(1); + } + p.outro("Stopped. Memories persisted to disk; restart anytime with: npx @agentmemory/agentmemory"); +} + +async function runMcp(): Promise { + await import("./mcp/standalone.js"); +} + +async function runConnectCmd(): Promise { + const { runConnect } = await import("./cli/connect/index.js"); + await runConnect(args.slice(1)); +} + +async function runImportJsonl(): Promise { + // Long-form flags that take a value. Their value tokens must be + // consumed alongside the flag so they don't leak into positional + // args (e.g. `--port 3112 import-jsonl` would otherwise turn + // 3112 into pathArg). + const VALUE_FLAGS = new Set(["--port", "--tools"]); + let maxFiles: number | undefined; + const tail = args.slice(1); + const positional: string[] = []; + for (let i = 0; i < tail.length; i++) { + const a = tail[i]!; + if (a === "--max-files") { + const raw = tail[i + 1]; + const parsed = raw !== undefined ? parseInt(raw, 10) : NaN; + if (Number.isInteger(parsed) && parsed > 0) { + maxFiles = parsed; + } else if (raw !== undefined) { + p.log.warn(`Ignoring --max-files ${raw}: expected a positive integer.`); + } + i++; + continue; + } + if (a.startsWith("--max-files=")) { + const raw = a.slice("--max-files=".length); + const parsed = parseInt(raw, 10); + if (Number.isInteger(parsed) && parsed > 0) { + maxFiles = parsed; + } else { + p.log.warn(`Ignoring --max-files=${raw}: expected a positive integer.`); + } + continue; + } + if (VALUE_FLAGS.has(a)) { + i++; + continue; + } + if (a.startsWith("-")) continue; + positional.push(a); + } + const pathArg = positional[0]; + + const port = getRestPort(); + const base = `http://localhost:${port}`; + + let probeOk = false; + let probeDetail = ""; + try { + const probe = await fetch(`${base}/agentmemory/livez`, { + signal: AbortSignal.timeout(2000), + }); + probeOk = probe.ok; + if (!probeOk) { + const probeBody = await probe.text().catch(() => ""); + probeDetail = `reachable but unhealthy (HTTP ${probe.status}${probeBody ? `: ${probeBody.slice(0, 200)}` : ""})`; + } + } catch (err) { + probeOk = false; + const msg = err instanceof Error ? err.message : String(err); + probeDetail = `unreachable (${msg})`; + } + if (!probeOk) { + p.log.error( + `agentmemory livez probe failed on port ${port}: ${probeDetail}. Start it with \`npx @agentmemory/agentmemory\` in another terminal, then re-run this command.`, + ); + process.exit(1); + } + + const body: Record = {}; + if (pathArg) body["path"] = pathArg; + if (maxFiles !== undefined) body["maxFiles"] = maxFiles; + + const headers: Record = { "content-type": "application/json" }; + const secret = process.env["AGENTMEMORY_SECRET"]; + if (secret) headers["authorization"] = `Bearer ${secret}`; + + p.log.info(`Importing JSONL from ${pathArg || "~/.claude/projects"}…`); + const spinner = p.spinner(); + spinner.start("scanning files"); + + try { + const res = await fetch(`${base}/agentmemory/replay/import-jsonl`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120_000), + }); + const text = await res.text(); + let json: { + success?: boolean; + error?: string; + imported?: number; + sessionIds?: string[]; + observations?: number; + discovered?: number; + truncated?: boolean; + traversalCapped?: boolean; + maxFiles?: number; + maxFilesUpperBound?: number; + } = {}; + if (text.length > 0) { + try { + json = JSON.parse(text); + } catch { + spinner.stop("failed"); + p.log.error( + `server returned non-JSON response (HTTP ${res.status}): ${text.slice(0, 200)}`, + ); + process.exit(1); + } + } + if (!res.ok || json.success !== true) { + spinner.stop("failed"); + const detail = + json.error || + (text.length === 0 + ? "empty response body" + : json.success === undefined + ? `HTTP ${res.status} (response missing success field)` + : `HTTP ${res.status}`); + if (res.status === 401) { + p.log.error( + `${detail}. Set AGENTMEMORY_SECRET to match the server's secret and re-run.`, + ); + } else if (res.status === 404) { + p.log.error( + `${detail}. The running agentmemory server does not expose /agentmemory/replay/import-jsonl — upgrade to v0.8.13 or later.`, + ); + } else { + p.log.error(detail); + } + process.exit(1); + } + spinner.stop( + `imported ${json.imported ?? 0} file(s), ${json.observations ?? 0} observation(s) across ${json.sessionIds?.length || 0} session(s)`, + ); + if (json.truncated) { + const cap = json.maxFiles ?? 200; + const upper = json.maxFilesUpperBound ?? 1000; + const discovered = json.discovered ?? 0; + const skipped = discovered - (json.imported ?? 0); + const discoveredLabel = json.traversalCapped + ? `${discovered}+ (traversal halted at safety cap)` + : String(discovered); + const baseMsg = `Hit the ${cap}-file scan cap; ${skipped} of ${discoveredLabel} discovered file(s) were skipped.`; + // If we already saw more than the server's hard cap (or the + // walker stopped early), bumping --max-files won't help on its + // own — recommend batching by subdirectory. + if (discovered > upper || json.traversalCapped) { + p.log.warn( + `${baseMsg} Tree exceeds the server's --max-files limit of ${upper}; ` + + `batch by subdirectory (run import-jsonl once per project under ~/.claude/projects).`, + ); + } else { + const suggested = Math.min( + Math.max((discovered || cap) + 100, cap * 2), + upper, + ); + p.log.warn( + `${baseMsg} Re-run with --max-files=${suggested} (max ${upper}) or batch by subdirectory.`, + ); + } + } + if (json.sessionIds && json.sessionIds.length > 0) { + p.log.info(`View at ${getViewerUrl()} → Replay tab`); + } + } catch (err) { + spinner.stop("failed"); + if (err instanceof Error && err.name === "TimeoutError") { + p.log.error("import timed out after 2 minutes"); + } else { + p.log.error(err instanceof Error ? err.message : String(err)); + } + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// `agentmemory remove` — clean uninstall. +// +// Planning logic lives in src/cli/remove-plan.ts so it's testable without +// touching $HOME. This function loads the manifest, builds the plan, +// double-confirms, then executes step by step. + +function loadConnectManifest(home: string): ConnectManifest | null { + const path = join(home, ".agentmemory", "backups", "connect-manifest.json"); + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if (Array.isArray(parsed?.installed)) { + return { installed: parsed.installed }; + } + return null; + } catch { + return null; + } +} + +function probeLocalBinIiiVersion(home: string): string | null { + const path = legacyLocalBinIii(home); + if (!existsSync(path)) return null; + return iiiBinVersion(path); +} + +function safeDelete(path: string): { ok: boolean; message: string } { + try { + if (!existsSync(path)) return { ok: true, message: `not present (${path})` }; + const st = statSync(path); + if (st.isDirectory()) { + rmSync(path, { recursive: true, force: true }); + } else { + unlinkSync(path); + } + return { ok: true, message: `deleted ${path}` }; + } catch (err) { + return { + ok: false, + message: `failed ${path}: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +async function runRemove(): Promise { + p.intro("agentmemory remove"); + const force = args.includes("--force"); + const keepData = args.includes("--keep-data"); + + const home = homedir(); + const connectManifest = loadConnectManifest(home); + const localBinIiiVersion = probeLocalBinIiiVersion(home); + + const options: RemoveOptions = { force, keepData }; + const plan = buildRemovePlan( + { + home, + pinnedVersion: IIPINNED_VERSION, + localBinIiiVersion, + connectManifest, + }, + options, + ); + + const applicable = plan.filter((it) => it.applicable); + if (applicable.length === 0) { + p.outro("Nothing to remove. agentmemory is already gone."); + return; + } + + p.note(formatPlan(plan), "destruction plan"); + + if (!force) { + const proceed = await p.confirm({ + message: "Proceed with these deletions?", + initialValue: false, + }); + if (p.isCancel(proceed) || proceed !== true) { + p.cancel("Cancelled. Nothing was deleted."); + return; + } + const sure = await p.confirm({ + message: "This is irreversible. Continue?", + initialValue: false, + }); + if (p.isCancel(sure) || sure !== true) { + p.cancel("Cancelled. Nothing was deleted."); + return; + } + } + + for (const item of plan) { + if (!item.applicable) continue; + + // alwaysAsk items get a per-item confirmation even with --force. + if (item.alwaysAsk) { + const ok = await p.confirm({ + message: `${item.description} — really delete${item.path ? ` ${item.path}` : ""}?`, + initialValue: false, + }); + if (p.isCancel(ok) || ok !== true) { + p.log.info(`skipped: ${item.id}`); + continue; + } + } + + if (item.id === "stop-engine") { + try { + const port = getRestPort(); + const portPids = findEnginePidsByPort(port); + const pidfilePid = readEnginePidfile(); + const cands = new Set(); + if (pidfilePid) cands.add(pidfilePid); + for (const pid of portPids) cands.add(pid); + for (const pid of cands) await signalAndWait(pid, "SIGTERM", 3000); + clearEnginePidfile(); + clearEngineState(); + p.log.success( + cands.size > 0 + ? `stopped engine (${cands.size} pid${cands.size === 1 ? "" : "s"})` + : "no engine running", + ); + } catch (err) { + p.log.warn( + `engine stop best-effort: ${err instanceof Error ? err.message : String(err)}`, + ); + } + continue; + } + + if (!item.path) continue; + const r = safeDelete(item.path); + if (r.ok) p.log.success(r.message); + else p.log.error(r.message); + } + + p.outro( + "Done. agentmemory cleanly removed. The npm package itself: npm uninstall -g @agentmemory/agentmemory", + ); +} + +const commands: Record Promise> = { + init: runInit, + connect: runConnectCmd, + status: runStatus, + doctor: runDoctor, + demo: runDemo, + upgrade: runUpgrade, + stop: runStop, + remove: runRemove, + mcp: runMcp, + "import-jsonl": runImportJsonl, +}; + +const handler = commands[args[0] ?? ""] ?? main; +handler().catch((err) => { + p.log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/src/cli/connect/antigravity.ts b/src/cli/connect/antigravity.ts new file mode 100644 index 0000000..559a7b7 --- /dev/null +++ b/src/cli/connect/antigravity.ts @@ -0,0 +1,25 @@ +import { homedir, platform } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +// Antigravity stores MCP config in mcp_config.json under its app +// support directory. The schema follows the standard MCP envelope — +// `{ mcpServers: { ... } }`. Path varies by platform: +// macOS: ~/Library/Application Support/Antigravity/User/mcp_config.json +// Linux: ~/.config/Antigravity/User/mcp_config.json +// Windows: %APPDATA%/Antigravity/User/mcp_config.json +// Connect is gated on win32 elsewhere; we cover macOS + Linux here. +const ANTIGRAVITY_DIR = + platform() === "darwin" + ? join(homedir(), "Library", "Application Support", "Antigravity", "User") + : join(homedir(), ".config", "Antigravity", "User"); + +export const adapter = createJsonMcpAdapter({ + name: "antigravity", + displayName: "Antigravity", + detectDir: ANTIGRAVITY_DIR, + configPath: join(ANTIGRAVITY_DIR, "mcp_config.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via mcp_config.json. Antigravity replaces Gemini CLI (sunset 2026-06-18).", +}); diff --git a/src/cli/connect/claude-code.ts b/src/cli/connect/claude-code.ts new file mode 100644 index 0000000..6d9ef06 --- /dev/null +++ b/src/cli/connect/claude-code.ts @@ -0,0 +1,178 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { + AGENTMEMORY_MCP_BLOCK, + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; +import { + buildMergedHooks, + findPluginRoot, + type HookManifest, +} from "./codex-hooks.js"; + +const CLAUDE_DIR = join(homedir(), ".claude"); +const CLAUDE_JSON = join(homedir(), ".claude.json"); +const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json"); + +type ClaudeMcpEntry = typeof AGENTMEMORY_MCP_BLOCK; +type ClaudeConfig = { + mcpServers?: Record; + [key: string]: unknown; +}; + +function entryMatches(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const e = entry as Record; + if (e["command"] !== "npx") return false; + const args = Array.isArray(e["args"]) ? (e["args"] as string[]) : []; + return args.includes("@agentmemory/mcp"); +} + +export const adapter: ConnectAdapter = { + name: "claude-code", + displayName: "Claude Code", + category: "native", + docs: "https://github.com/rohitg00/agentmemory#claude-code-one-block-paste-it", + protocolNote: + "→ Using MCP. Hooks are also available — see https://github.com/rohitg00/agentmemory#claude-code-one-block-paste-it.", + + detect(): boolean { + return existsSync(CLAUDE_DIR); + }, + + async install(opts: ConnectOptions): Promise { + const existing = readJsonSafe(CLAUDE_JSON); + const next: ClaudeConfig = existing ? { ...existing } : {}; + const servers: Record = { + ...((next.mcpServers as Record) ?? {}), + }; + + const alreadyHas = entryMatches(servers["agentmemory"]); + if (alreadyHas && !opts.force) { + logAlreadyWired("Claude Code", CLAUDE_JSON); + // --with-hooks is independent of MCP wiring (issue #508). Run the + // hooks fallback even when MCP is already in place so users with a + // healthy MCP setup can still pick up version-stable hook paths. + if (opts.withHooks) { + const hookResult = installClaudeHooks(opts); + if (hookResult.kind === "skipped") { + p.log.warn( + `Claude Code hooks fallback skipped: ${hookResult.reason}.`, + ); + } + } + return { kind: "already-wired", mutatedPath: CLAUDE_JSON }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} mcpServers.agentmemory in ${CLAUDE_JSON}`, + ); + return { kind: "installed", mutatedPath: CLAUDE_JSON }; + } + + let backupPath: string | undefined; + if (existsSync(CLAUDE_JSON)) { + backupPath = backupFile(CLAUDE_JSON, "claude-code"); + logBackup(backupPath); + } else { + mkdirSync(CLAUDE_DIR, { recursive: true }); + writeFileSync(CLAUDE_JSON, "{}\n", "utf-8"); + } + + servers["agentmemory"] = AGENTMEMORY_MCP_BLOCK; + next.mcpServers = servers; + writeJsonAtomic(CLAUDE_JSON, next); + + const verify = readJsonSafe(CLAUDE_JSON); + if (!entryMatches(verify?.mcpServers?.["agentmemory"])) { + p.log.error( + `Verification failed: ${CLAUDE_JSON} did not contain mcpServers.agentmemory after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled("Claude Code", CLAUDE_JSON); + p.log.info( + "Restart Claude Code (or run `/mcp` inside a session) to pick up the new server.", + ); + + if (opts.withHooks) { + const hookResult = installClaudeHooks(opts); + if (hookResult.kind === "skipped") { + p.log.warn( + `Claude Code hooks fallback skipped: ${hookResult.reason}. MCP wiring still applied.`, + ); + } + } + + return { kind: "installed", mutatedPath: CLAUDE_JSON, backupPath }; + }, +}; + +/** + * Merge the bundled `plugin/hooks/hooks.json` into + * `~/.claude/settings.json`'s top-level `hooks` field with absolute + * script paths. Use this when agentmemory is NOT installed through + * `/plugin marketplace add` (e.g. MCP standalone wiring), so the + * hook scripts survive version bumps without `${CLAUDE_PLUGIN_ROOT}` + * expansion (issue #508). + * + * Re-install strips entries whose command points under + * `/scripts/`; unrelated user hook entries survive. + */ +function installClaudeHooks(opts: ConnectOptions): ConnectResult { + let pluginRoot: string; + try { + pluginRoot = findPluginRoot(); + } catch (err) { + return { + kind: "skipped", + reason: err instanceof Error ? err.message : String(err), + }; + } + + type ClaudeSettings = { hooks?: HookManifest["hooks"]; [key: string]: unknown }; + const existing = readJsonSafe(CLAUDE_SETTINGS) ?? {}; + const existingHooks: HookManifest | null = existing.hooks + ? { hooks: existing.hooks } + : null; + const merged = buildMergedHooks(existingHooks, pluginRoot, "hooks.json"); + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would merge agentmemory hook entries into ${CLAUDE_SETTINGS} (${Object.keys(merged.hooks).length} event(s))`, + ); + return { kind: "installed", mutatedPath: CLAUDE_SETTINGS }; + } + + let backupPath: string | undefined; + if (existsSync(CLAUDE_SETTINGS)) { + backupPath = backupFile(CLAUDE_SETTINGS, "claude-settings", "json"); + logBackup(backupPath); + } else { + mkdirSync(CLAUDE_DIR, { recursive: true }); + } + + const next: ClaudeSettings = { ...existing, hooks: merged.hooks }; + writeJsonAtomic(CLAUDE_SETTINGS, next); + + logInstalled("Claude Code hooks (workaround for #508)", CLAUDE_SETTINGS); + p.log.info( + "User-scope hook entries reference absolute paths under the bundled plugin/ dir. Re-run `agentmemory connect claude-code --with-hooks` after upgrading agentmemory to refresh them.", + ); + + return { + kind: "installed", + mutatedPath: CLAUDE_SETTINGS, + ...(backupPath !== undefined && { backupPath }), + }; +} diff --git a/src/cli/connect/cline.ts b/src/cli/connect/cline.ts new file mode 100644 index 0000000..3fb44ae --- /dev/null +++ b/src/cli/connect/cline.ts @@ -0,0 +1,19 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +// Cline CLI stores MCP server config at ~/.cline/mcp.json with the +// canonical `mcpServers` wrapper — same schema as Claude Code with +// optional `disabled` and `autoApprove` fields per entry. +// VS Code extension users manage MCP through Cline's Settings UI; +// this adapter targets the standalone CLI surface. +// Source: github.com/cline/cline/blob/main/docs/mcp/mcp-overview.mdx +export const adapter = createJsonMcpAdapter({ + name: "cline", + displayName: "Cline", + detectDir: join(homedir(), ".cline"), + configPath: join(homedir(), ".cline", "mcp.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.cline/mcp.json (CLI). VS Code users: add the same block via Cline Settings → MCP Servers → Edit JSON.", +}); diff --git a/src/cli/connect/codex-hooks.ts b/src/cli/connect/codex-hooks.ts new file mode 100644 index 0000000..ad19877 --- /dev/null +++ b/src/cli/connect/codex-hooks.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Workaround for openai/codex#16430 — Codex Desktop does not dispatch + * plugin-local `hooks.json` even though both `CodexHooks` and `PluginHooks` + * feature flags are stable + default-enabled in + * `codex-rs/features/src/lib.rs`. Until upstream fixes plugin-scope + * dispatch, the same hook commands can be mirrored into the global + * `~/.codex/hooks.json`, which is loaded reliably. + * + * This module builds that mirror, with `${CLAUDE_PLUGIN_ROOT}` resolved to + * the bundled `plugin/` directory so the user-scope file does not depend + * on env-var expansion (Codex only injects `CLAUDE_PLUGIN_ROOT` for + * plugin-scope hooks). + * + * Identification on re-install: every command we write contains the + * resolved `/scripts/` prefix, so subsequent installs can + * strip our entries and re-add cleanly without touching the user's other + * hook entries. + */ + +type HookHandler = { type: string; command: string }; +type HookEntry = { matcher?: string; hooks: HookHandler[] }; +export type HookManifest = { hooks: Record }; + +/** + * Locate the bundled `plugin/` directory at runtime. Walks up from the + * module's own location looking for `plugin/scripts/` + `plugin/hooks/`, + * both shipped via the npm `files` field. Works for both `dist/cli.mjs` + * (bundled) and `src/cli/connect/codex-hooks.ts` (dev) layouts. + */ +export function findPluginRoot(startUrl: string = import.meta.url): string { + const here = dirname(fileURLToPath(startUrl)); + let dir = here; + for (let i = 0; i < 12; i++) { + if ( + existsSync(join(dir, "plugin", "scripts")) && + existsSync(join(dir, "plugin", "hooks")) + ) { + return resolve(join(dir, "plugin")); + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + `agentmemory: could not locate bundled plugin/ directory (searched up from ${here})`, + ); +} + +/** + * Build the merged hooks.json content. + * + * 1. Strip any entry from `existing` whose first hook command points + * under `/scripts/`. This lets us re-install idempotently + * without leaving stale references. + * 2. Append fresh entries from the bundled Codex manifest with + * `${CLAUDE_PLUGIN_ROOT}` rewritten to the absolute plugin path. + * Matcher values from the bundled manifest are preserved so PreToolUse + * event routing keeps working. + */ +export function buildMergedHooks( + existing: HookManifest | null, + pluginRoot: string, + manifestFile = "hooks.codex.json", +): HookManifest { + const bundledManifestPath = join(pluginRoot, "hooks", manifestFile); + const ours = JSON.parse(readFileSync(bundledManifestPath, "utf-8")) as HookManifest; + const scriptsDir = join(pluginRoot, "scripts"); + + const out: HookManifest = { hooks: {} }; + + if (existing?.hooks) { + for (const [event, entries] of Object.entries(existing.hooks)) { + const kept = entries.filter((entry) => !isAgentmemoryEntry(entry, scriptsDir)); + if (kept.length > 0) out.hooks[event] = kept; + } + } + + for (const [event, entries] of Object.entries(ours.hooks)) { + const resolvedEntries: HookEntry[] = entries.map((entry) => { + const next: HookEntry = { + hooks: entry.hooks.map((handler) => ({ + type: handler.type, + command: handler.command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot), + })), + }; + if (entry.matcher !== undefined) next.matcher = entry.matcher; + return next; + }); + out.hooks[event] = [...(out.hooks[event] ?? []), ...resolvedEntries]; + } + + return out; +} + +function isAgentmemoryEntry(entry: HookEntry, scriptsDir: string): boolean { + const normalizedScriptsDir = normalizePathForCommandMatch(scriptsDir); + return entry.hooks.some((handler) => + normalizePathForCommandMatch(handler.command).includes(normalizedScriptsDir), + ); +} + +function normalizePathForCommandMatch(value: string): string { + return value.replace(/\\/g, "/"); +} diff --git a/src/cli/connect/codex.ts b/src/cli/connect/codex.ts new file mode 100644 index 0000000..3dbc188 --- /dev/null +++ b/src/cli/connect/codex.ts @@ -0,0 +1,181 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, dirname } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; +import { + buildMergedHooks, + findPluginRoot, + type HookManifest, +} from "./codex-hooks.js"; + +const CODEX_DIR = join(homedir(), ".codex"); +const CODEX_TOML = join(CODEX_DIR, "config.toml"); +const CODEX_HOOKS = join(CODEX_DIR, "hooks.json"); + +const TOML_BLOCK = `[mcp_servers.agentmemory] +command = "npx" +args = ["-y", "@agentmemory/mcp"] + +[mcp_servers.agentmemory.env] +AGENTMEMORY_URL = "http://localhost:3111" +`; + +const SECTION_HEADER = "[mcp_servers.agentmemory]"; + +function isWiredText(toml: string): boolean { + return toml.includes(SECTION_HEADER); +} + +function stripExistingBlock(toml: string): string { + const lines = toml.split(/\r?\n/); + const out: string[] = []; + let skipping = false; + for (const line of lines) { + const trimmed = line.trim(); + if ( + trimmed === SECTION_HEADER || + trimmed === "[mcp_servers.agentmemory.env]" + ) { + skipping = true; + continue; + } + if ( + skipping && + trimmed.startsWith("[") && + trimmed !== "[mcp_servers.agentmemory.env]" + ) { + skipping = false; + } + if (!skipping) out.push(line); + } + return out.join("\n").replace(/\n{3,}$/, "\n\n").trimEnd() + "\n"; +} + +export const adapter: ConnectAdapter = { + name: "codex", + displayName: "Codex CLI", + category: "native", + docs: "https://github.com/rohitg00/agentmemory#codex-cli-codex-plugin-platform", + protocolNote: + "→ Using MCP. Hooks ship via the Codex plugin; on Codex Desktop, also pass --with-hooks to install the global hooks.json workaround for openai/codex#16430.", + + detect(): boolean { + return existsSync(CODEX_DIR); + }, + + async install(opts: ConnectOptions): Promise { + const exists = existsSync(CODEX_TOML); + const current = exists ? readFileSync(CODEX_TOML, "utf-8") : ""; + const wired = isWiredText(current); + + if (wired && !opts.force) { + logAlreadyWired("Codex CLI", CODEX_TOML); + return { kind: "already-wired", mutatedPath: CODEX_TOML }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${wired ? "rewrite" : "append"} [mcp_servers.agentmemory] in ${CODEX_TOML}`, + ); + if (opts.withHooks) installCodexHooks(opts); + return { kind: "installed", mutatedPath: CODEX_TOML }; + } + + let backupPath: string | undefined; + if (exists) { + backupPath = backupFile(CODEX_TOML, "codex", "toml"); + logBackup(backupPath); + } else { + mkdirSync(dirname(CODEX_TOML), { recursive: true }); + } + + const cleaned = wired ? stripExistingBlock(current) : current; + const joiner = cleaned.length === 0 || cleaned.endsWith("\n") ? "" : "\n"; + const next = `${cleaned}${joiner}${cleaned.length > 0 ? "\n" : ""}${TOML_BLOCK}`; + writeFileSync(CODEX_TOML, next, "utf-8"); + + const verify = readFileSync(CODEX_TOML, "utf-8"); + if (!isWiredText(verify)) { + p.log.error( + `Verification failed: ${CODEX_TOML} did not contain ${SECTION_HEADER} after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled("Codex CLI", CODEX_TOML); + p.log.info( + "Codex picks up MCP servers on next launch. For the deeper plugin install, run: codex plugin marketplace add rohitg00/agentmemory && codex plugin add agentmemory@agentmemory", + ); + + if (opts.withHooks) { + const hookResult = installCodexHooks(opts); + if (hookResult.kind === "skipped") { + p.log.warn( + `Codex hooks fallback skipped: ${hookResult.reason}. MCP wiring still applied.`, + ); + } + } + + return { + kind: "installed", + mutatedPath: CODEX_TOML, + ...(backupPath !== undefined && { backupPath }), + }; + }, +}; + +/** + * Install the global `~/.codex/hooks.json` fallback. See + * `codex-hooks.ts` for context (openai/codex#16430). Returns a result + * describing the side effect for the caller's summary; failures here do + * not roll back the MCP wiring. + */ +function installCodexHooks(opts: ConnectOptions): ConnectResult { + let pluginRoot: string; + try { + pluginRoot = findPluginRoot(); + } catch (err) { + return { + kind: "skipped", + reason: err instanceof Error ? err.message : String(err), + }; + } + + const existing = readJsonSafe(CODEX_HOOKS); + const merged = buildMergedHooks(existing, pluginRoot); + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${existing ? "merge" : "create"} ${CODEX_HOOKS} with ${Object.keys(merged.hooks).length} event(s)`, + ); + return { kind: "installed", mutatedPath: CODEX_HOOKS }; + } + + let backupPath: string | undefined; + if (existsSync(CODEX_HOOKS)) { + backupPath = backupFile(CODEX_HOOKS, "codex-hooks", "json"); + logBackup(backupPath); + } + + writeJsonAtomic(CODEX_HOOKS, merged); + + logInstalled("Codex hooks (workaround for openai/codex#16430)", CODEX_HOOKS); + 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.", + ); + + return { + kind: "installed", + mutatedPath: CODEX_HOOKS, + ...(backupPath !== undefined && { backupPath }), + }; +} diff --git a/src/cli/connect/continue.ts b/src/cli/connect/continue.ts new file mode 100644 index 0000000..c09107c --- /dev/null +++ b/src/cli/connect/continue.ts @@ -0,0 +1,164 @@ +import { existsSync, mkdirSync, writeFileSync } 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 { + AGENTMEMORY_MCP_BLOCK, + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +// Continue.dev v1+ prefers ~/.continue/config.yaml; config.json is +// deprecated and ignored when yaml is present. Three branches: +// - config.yaml exists → emit stub with manual edit instructions +// (no YAML dep in tree; preserving comments/anchors safely needs it) +// - config.json exists → modify it (legacy path still loaded when no yaml) +// - neither → create config.yaml from scratch (no merge risk) +// Source: docs.continue.dev/reference/yaml-migration +const CONTINUE_DIR = join(homedir(), ".continue"); +const YAML_PATH = join(CONTINUE_DIR, "config.yaml"); +const JSON_PATH = join(CONTINUE_DIR, "config.json"); + +type ContinueEntry = { + name: string; + command: string; + args: string[]; + env?: Record; +}; + +type ContinueJsonConfig = { + mcpServers?: ContinueEntry[]; + [key: string]: unknown; +}; + +function buildEntry(): ContinueEntry { + return { + name: "agentmemory", + command: AGENTMEMORY_MCP_BLOCK.command, + args: [...AGENTMEMORY_MCP_BLOCK.args], + env: { ...AGENTMEMORY_MCP_BLOCK.env }, + }; +} + +function entryIsAgentmemory(entry: ContinueEntry | undefined): boolean { + if (!entry) return false; + return entry.name === "agentmemory" && entry.args.includes("@agentmemory/mcp"); +} + +// Minimal YAML emitter for the agentmemory entry. Quotes string values +// that contain ${ ... } expansion to keep parsers happy. Only used when +// creating a fresh config.yaml — never when modifying an existing one. +function renderFreshYaml(): string { + const e = buildEntry(); + const envLines = Object.entries(e.env ?? {}) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + return [ + "mcpServers:", + ` - name: ${e.name}`, + ` command: ${e.command}`, + " args:", + ...e.args.map((a) => ` - "${a}"`), + " env:", + envLines, + "", + ].join("\n"); +} + +export const adapter: ConnectAdapter = { + name: "continue", + displayName: "Continue", + category: "mcp", + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.continue/config.yaml (preferred) or config.json (legacy, only when no yaml).", + + detect(): boolean { + return existsSync(CONTINUE_DIR); + }, + + async install(opts: ConnectOptions): Promise { + const yamlExists = existsSync(YAML_PATH); + const jsonExists = existsSync(JSON_PATH); + + // Branch 1: yaml present — refuse to silently mutate user's yaml + // config (preserving comments/anchors needs a proper parser). + if (yamlExists) { + const indented = renderFreshYaml() + .split("\n") + .map((l) => (l ? ` ${l}` : l)) + .join("\n"); + const manual = `\nMerge this block into ~/.continue/config.yaml (the snippet already includes the top-level mcpServers key — if your config already has a mcpServers list, append the agentmemory entry to it instead of duplicating the key):\n\n${indented}`; + p.log.info( + `Continue: ${YAML_PATH} already exists. Manual edit needed.${manual}`, + ); + return { kind: "stub", reason: "config.yaml-needs-manual-edit" }; + } + + // Branch 2: legacy json present — modify in place. + if (jsonExists) { + const existing = readJsonSafe(JSON_PATH); + const next: ContinueJsonConfig = existing ? { ...existing } : {}; + const servers = Array.isArray(next.mcpServers) + ? [...next.mcpServers] + : []; + + const idx = servers.findIndex((s) => s?.name === "agentmemory"); + const alreadyHas = idx >= 0 && entryIsAgentmemory(servers[idx]); + if (alreadyHas && !opts.force) { + logAlreadyWired("Continue", JSON_PATH); + return { kind: "already-wired", mutatedPath: JSON_PATH }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} mcpServers[agentmemory] in ${JSON_PATH}`, + ); + return { kind: "installed", mutatedPath: JSON_PATH }; + } + + const backupPath = backupFile(JSON_PATH, "continue"); + logBackup(backupPath); + + const entry = buildEntry(); + if (idx >= 0) servers[idx] = entry; + else servers.push(entry); + next.mcpServers = servers; + writeJsonAtomic(JSON_PATH, next); + + const verify = readJsonSafe(JSON_PATH); + const verifyEntry = verify?.mcpServers?.find( + (s) => s?.name === "agentmemory", + ); + if (!entryIsAgentmemory(verifyEntry)) { + p.log.error( + `Verification failed: ${JSON_PATH} did not contain mcpServers[agentmemory] after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled("Continue (legacy config.json)", JSON_PATH); + return { + kind: "installed", + mutatedPath: JSON_PATH, + backupPath, + }; + } + + // Branch 3: neither exists — create config.yaml from scratch (modern path). + if (opts.dryRun) { + p.log.info(`[dry-run] Would create ${YAML_PATH} with agentmemory entry`); + return { kind: "installed", mutatedPath: YAML_PATH }; + } + + mkdirSync(dirname(YAML_PATH), { recursive: true }); + writeFileSync(YAML_PATH, renderFreshYaml(), "utf-8"); + logInstalled("Continue", YAML_PATH); + return { kind: "installed", mutatedPath: YAML_PATH }; + }, +}; diff --git a/src/cli/connect/copilot-cli.ts b/src/cli/connect/copilot-cli.ts new file mode 100644 index 0000000..1f7b43d --- /dev/null +++ b/src/cli/connect/copilot-cli.ts @@ -0,0 +1,92 @@ +import { existsSync, mkdirSync } 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 { + AGENTMEMORY_COPILOT_MCP_BLOCK, + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +const COPILOT_DIR = process.env["COPILOT_HOME"] || join(homedir(), ".copilot"); +const COPILOT_MCP_JSON = join(COPILOT_DIR, "mcp-config.json"); + +type CopilotMcpEntry = typeof AGENTMEMORY_COPILOT_MCP_BLOCK; +type CopilotConfig = { + mcpServers?: Record; + [key: string]: unknown; +}; + +function entryMatches(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + return JSON.stringify(entry) === JSON.stringify(AGENTMEMORY_COPILOT_MCP_BLOCK); +} + +export const adapter: ConnectAdapter = { + name: "copilot-cli", + displayName: "GitHub Copilot CLI", + category: "native", + docs: "https://github.com/rohitg00/agentmemory#github-copilot-cli", + protocolNote: + "→ Using MCP. Install the plugin too for full hooks/skills coverage.", + + detect(): boolean { + return existsSync(COPILOT_DIR); + }, + + async install(opts: ConnectOptions): Promise { + const existing = readJsonSafe(COPILOT_MCP_JSON); + const next: CopilotConfig = existing ? { ...existing } : {}; + const servers: Record = { + ...((next.mcpServers as Record) ?? {}), + }; + + const alreadyHas = entryMatches(servers["agentmemory"]); + if (alreadyHas && !opts.force) { + logAlreadyWired("GitHub Copilot CLI", COPILOT_MCP_JSON); + return { kind: "already-wired", mutatedPath: COPILOT_MCP_JSON }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} mcpServers.agentmemory in ${COPILOT_MCP_JSON}`, + ); + return { kind: "installed", mutatedPath: COPILOT_MCP_JSON }; + } + + let backupPath: string | undefined; + if (existsSync(COPILOT_MCP_JSON)) { + backupPath = backupFile(COPILOT_MCP_JSON, "copilot-cli"); + logBackup(backupPath); + } else { + mkdirSync(dirname(COPILOT_MCP_JSON), { recursive: true }); + } + + servers["agentmemory"] = AGENTMEMORY_COPILOT_MCP_BLOCK; + next.mcpServers = servers; + writeJsonAtomic(COPILOT_MCP_JSON, next); + + const verify = readJsonSafe(COPILOT_MCP_JSON); + if (!entryMatches(verify?.mcpServers?.["agentmemory"])) { + p.log.error( + `Verification failed: ${COPILOT_MCP_JSON} did not contain mcpServers.agentmemory after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled("GitHub Copilot CLI", COPILOT_MCP_JSON); + p.log.info( + "Copilot picks up MCP servers on next launch or after `/mcp`. Install the plugin too for full hooks/skills.", + ); + return { + kind: "installed", + mutatedPath: COPILOT_MCP_JSON, + ...(backupPath !== undefined && { backupPath }), + }; + }, +}; diff --git a/src/cli/connect/cursor.ts b/src/cli/connect/cursor.ts new file mode 100644 index 0000000..fb4c5af --- /dev/null +++ b/src/cli/connect/cursor.ts @@ -0,0 +1,13 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +export const adapter = createJsonMcpAdapter({ + name: "cursor", + displayName: "Cursor", + detectDir: join(homedir(), ".cursor"), + configPath: join(homedir(), ".cursor", "mcp.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP (the only protocol Cursor speaks). Memory bridge runs at :3111 underneath.", +}); diff --git a/src/cli/connect/droid.ts b/src/cli/connect/droid.ts new file mode 100644 index 0000000..92ae69e --- /dev/null +++ b/src/cli/connect/droid.ts @@ -0,0 +1,21 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +// Factory.ai's Droid CLI stores MCP server config at ~/.factory/mcp.json +// with the canonical `mcpServers` wrapper. Project-scoped overrides live +// at /.factory/mcp.json. Each entry adds an optional `type` field +// ("stdio" | "http") and `disabled` boolean — agentmemory's stdio block +// works against the same shape without needing the explicit type tag. +// Source: docs.factory.ai/cli/configuration/mcp +export const adapter = createJsonMcpAdapter({ + name: "droid", + displayName: "Droid (Factory.ai)", + detectDir: join(homedir(), ".factory"), + configPath: join(homedir(), ".factory", "mcp.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.factory/mcp.json. The `/mcp` slash command inside droid lists configured servers.", + // Droid requires `type` per the documented schema. stdio for npx-spawned shim. + extraEntryFields: { type: "stdio" }, +}); diff --git a/src/cli/connect/gemini-cli.ts b/src/cli/connect/gemini-cli.ts new file mode 100644 index 0000000..6142a39 --- /dev/null +++ b/src/cli/connect/gemini-cli.ts @@ -0,0 +1,13 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +export const adapter = createJsonMcpAdapter({ + name: "gemini-cli", + displayName: "Gemini CLI", + detectDir: join(homedir(), ".gemini"), + configPath: join(homedir(), ".gemini", "settings.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP (the only protocol Gemini CLI speaks). Memory bridge runs at :3111 underneath.", +}); diff --git a/src/cli/connect/hermes.ts b/src/cli/connect/hermes.ts new file mode 100644 index 0000000..9720472 --- /dev/null +++ b/src/cli/connect/hermes.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; + +const HERMES_DIR = join(homedir(), ".hermes"); +const HERMES_CONFIG = join(HERMES_DIR, "config.yaml"); +const DOCS = "https://github.com/rohitg00/agentmemory/tree/main/integrations/hermes"; + +export const adapter: ConnectAdapter = { + name: "hermes", + displayName: "Hermes Agent", + category: "native", + docs: DOCS, + protocolNote: + "→ Using MCP. Hooks are also available — see https://github.com/rohitg00/agentmemory/tree/main/integrations/hermes.", + + detect(): boolean { + return existsSync(HERMES_DIR); + }, + + async install(_opts: ConnectOptions): Promise { + p.log.warn( + "Hermes uses YAML config. Automated merge isn't implemented yet — manual install required.", + ); + p.note( + [ + `Add to ${HERMES_CONFIG}:`, + "", + " mcp_servers:", + " agentmemory:", + " command: npx", + ' args: ["-y", "@agentmemory/mcp"]', + "", + " memory:", + " provider: agentmemory", + "", + `Full guide: ${DOCS}`, + ].join("\n"), + "Hermes manual install", + ); + return { + kind: "stub", + reason: "yaml-merge-not-implemented", + }; + }, +}; diff --git a/src/cli/connect/index.ts b/src/cli/connect/index.ts new file mode 100644 index 0000000..0d9412c --- /dev/null +++ b/src/cli/connect/index.ts @@ -0,0 +1,209 @@ +import { platform } from "node:os"; +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { adapter as antigravity } from "./antigravity.js"; +import { adapter as claudeCode } from "./claude-code.js"; +import { adapter as cline } from "./cline.js"; +import { adapter as copilotCli } from "./copilot-cli.js"; +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 geminiCli } from "./gemini-cli.js"; +import { adapter as hermes } from "./hermes.js"; +import { adapter as kiro } from "./kiro.js"; +import { adapter as openclaw } from "./openclaw.js"; +import { adapter as opencode } from "./opencode.js"; +import { adapter as openhuman } from "./openhuman.js"; +import { adapter as pi } from "./pi.js"; +import { adapter as qwen } from "./qwen.js"; +import { adapter as warp } from "./warp.js"; +import { adapter as zed } from "./zed.js"; + +export const ADAPTERS: readonly ConnectAdapter[] = [ + claudeCode, + copilotCli, + codex, + cursor, + geminiCli, + qwen, + antigravity, + kiro, + warp, + cline, + continueDev, + zed, + droid, + opencode, + openclaw, + hermes, + pi, + openhuman, +]; + +export function resolveAdapter(name: string): ConnectAdapter | null { + const lower = name.toLowerCase(); + return ADAPTERS.find((a) => a.name === lower) ?? null; +} + +export function knownAgents(): string[] { + return ADAPTERS.map((a) => a.name); +} + +function parseFlags(args: string[]): { + dryRun: boolean; + force: boolean; + all: boolean; + withHooks: boolean; + positional: string[]; +} { + const positional: string[] = []; + let dryRun = false; + let force = false; + let all = false; + let withHooks = false; + for (const a of args) { + if (a === "--dry-run") dryRun = true; + else if (a === "--force") force = true; + else if (a === "--all") all = true; + else if (a === "--with-hooks") withHooks = true; + else if (!a.startsWith("-")) positional.push(a); + } + return { dryRun, force, all, withHooks, positional }; +} + +export async function runAdapter( + adapter: ConnectAdapter, + opts: ConnectOptions, +): Promise { + if (!adapter.detect()) { + p.log.warn( + `${adapter.displayName}: not detected on this machine (skipping).${adapter.docs ? ` Docs: ${adapter.docs}` : ""}`, + ); + return { kind: "skipped", reason: "not-detected" }; + } + p.log.step(`Wiring ${adapter.displayName}…`); + if (adapter.protocolNote) { + p.log.message(adapter.protocolNote); + } + try { + return await adapter.install(opts); + } catch (err) { + p.log.error( + `${adapter.displayName}: ${err instanceof Error ? err.message : String(err)}`, + ); + return { kind: "skipped", reason: "exception" }; + } +} + +export async function runConnect(args: string[]): Promise { + const { dryRun, force, all, withHooks, positional } = parseFlags(args); + const allowWindowsAdapter = + positional.length === 1 && positional[0]?.toLowerCase() === "copilot-cli"; + if (platform() === "win32" && !allowWindowsAdapter) { + p.intro("agentmemory connect"); + p.log.warn( + "Windows: automated `connect` is not supported yet. See https://github.com/rohitg00/agentmemory#other-agents for manual install steps.", + ); + p.outro("Windows: manual install required — see docs"); + return; + } + + const opts: ConnectOptions = { dryRun, force, withHooks }; + + p.intro("agentmemory connect"); + + if (positional.length === 0 && !all) { + const detected = ADAPTERS.filter((a) => a.detect()); + if (detected.length === 0) { + p.log.error("No supported agents detected on this machine."); + p.outro(`Supported: ${knownAgents().join(", ")}`); + process.exit(1); + } + const picked = await p.multiselect({ + message: "Wire agentmemory into which agents?", + options: detected.map((a) => ({ value: a.name, label: a.displayName })), + required: true, + }); + if (p.isCancel(picked)) { + p.cancel("Cancelled."); + return; + } + const results: { name: string; result: ConnectResult }[] = []; + for (const name of picked as string[]) { + const adapter = resolveAdapter(name); + if (!adapter) continue; + results.push({ name, result: await runAdapter(adapter, opts) }); + } + summarize(results); + return; + } + + if (all) { + const detected = ADAPTERS.filter((a) => a.detect()); + if (detected.length === 0) { + p.log.error("No supported agents detected on this machine."); + process.exit(1); + } + const results: { name: string; result: ConnectResult }[] = []; + for (const adapter of detected) { + results.push({ + name: adapter.name, + result: await runAdapter(adapter, opts), + }); + } + summarize(results); + return; + } + + const agentName = positional[0]!; + const adapter = resolveAdapter(agentName); + if (!adapter) { + p.log.error(`Unknown agent: ${agentName}`); + p.outro(`Supported: ${knownAgents().join(", ")}`); + process.exit(1); + } + + const result = await runAdapter(adapter, opts); + summarize([{ name: agentName, result }]); + if (result.kind === "skipped" && (result as { reason: string }).reason !== "not-detected") { + process.exit(1); + } +} + +function summarize( + results: { name: string; result: ConnectResult }[], +): void { + const lines = results.map(({ name, result }) => { + switch (result.kind) { + case "installed": + return ` ${pc.green("✓")} ${pc.bold(name)}${result.mutatedPath ? ` ${pc.dim("→")} ${pc.cyan(result.mutatedPath)}` : ""}`; + case "already-wired": + return ` ${pc.green("✓")} ${pc.bold(name)} ${pc.dim("(already wired)")}`; + case "stub": + return ` ${pc.yellow("⚠")} ${pc.bold(name)} ${pc.yellow(`(manual install required: ${result.reason})`)}`; + case "skipped": + return ` ${pc.red("✗")} ${pc.bold(name)} ${pc.dim(`(skipped: ${result.reason})`)}`; + } + }); + p.note(lines.join("\n"), "summary"); + + const stubs = results.filter((r) => r.result.kind === "stub"); + if (stubs.length > 0) { + p.log.info( + `${stubs.length} agent(s) require manual install — see docs links above.`, + ); + } + + const wiredAny = results.some( + (r) => r.result.kind === "installed" || r.result.kind === "already-wired", + ); + if (wiredAny) { + p.log.info( + "Next: install agentmemory's 15 skills into the same agent(s) so they know when to call the tools:\n npx skills add rohitg00/agentmemory -y", + ); + } + + p.outro("Restart any wired agent (or open a new session) to pick up agentmemory."); +} diff --git a/src/cli/connect/json-mcp-adapter.ts b/src/cli/connect/json-mcp-adapter.ts new file mode 100644 index 0000000..35998e0 --- /dev/null +++ b/src/cli/connect/json-mcp-adapter.ts @@ -0,0 +1,116 @@ +import { existsSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; +import { + AGENTMEMORY_MCP_BLOCK, + backupFile, + logAlreadyWired, + logBackup, + logInstalled, + readJsonSafe, + writeJsonAtomic, +} from "./util.js"; + +export type JsonMcpAdapterConfig = { + name: string; + displayName: string; + detectDir: string; + configPath: string; + docs?: string; + protocolNote?: string; + // Integration style for onboarding grouping. Defaults to "mcp" since a + // JSON MCP config writer is MCP-only by construction; hosts that also + // ship hooks (e.g. OpenClaw) pass "native". + category?: "native" | "mcp"; + // Wrapper key under which servers live. Default "mcpServers". + // Zed uses "context_servers"; otherwise same shape. + wrapperKey?: string; + // Extra fields merged into the agentmemory entry. Droid requires + // type: "stdio"; other hosts ignore unknown fields. + extraEntryFields?: Record; +}; + +type McpEntry = typeof AGENTMEMORY_MCP_BLOCK; +type McpConfig = Record; + +function entryMatches(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const e = entry as Record; + if (e["command"] !== "npx") return false; + const args = Array.isArray(e["args"]) ? (e["args"] as string[]) : []; + return args.includes("@agentmemory/mcp"); +} + +export function createJsonMcpAdapter( + config: JsonMcpAdapterConfig, +): ConnectAdapter { + const wrapperKey = config.wrapperKey ?? "mcpServers"; + return { + name: config.name, + displayName: config.displayName, + category: config.category ?? "mcp", + ...(config.docs !== undefined && { docs: config.docs }), + ...(config.protocolNote !== undefined && { + protocolNote: config.protocolNote, + }), + + detect(): boolean { + return existsSync(config.detectDir); + }, + + async install(opts: ConnectOptions): Promise { + const existing = readJsonSafe(config.configPath); + const next: McpConfig = existing ? { ...existing } : {}; + const servers: Record = { + ...((next[wrapperKey] as Record) ?? {}), + }; + + const alreadyHas = entryMatches(servers["agentmemory"]); + if (alreadyHas && !opts.force) { + logAlreadyWired(config.displayName, config.configPath); + return { kind: "already-wired", mutatedPath: config.configPath }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} ${wrapperKey}.agentmemory in ${config.configPath}`, + ); + return { kind: "installed", mutatedPath: config.configPath }; + } + + let backupPath: string | undefined; + if (existsSync(config.configPath)) { + backupPath = backupFile(config.configPath, config.name); + logBackup(backupPath); + } else { + mkdirSync(dirname(config.configPath), { recursive: true }); + } + + servers["agentmemory"] = { + ...AGENTMEMORY_MCP_BLOCK, + ...(config.extraEntryFields ?? {}), + }; + next[wrapperKey] = servers; + writeJsonAtomic(config.configPath, next); + + const verify = readJsonSafe(config.configPath); + const verifyServers = verify?.[wrapperKey] as + | Record + | undefined; + if (!entryMatches(verifyServers?.["agentmemory"])) { + p.log.error( + `Verification failed: ${config.configPath} did not contain ${wrapperKey}.agentmemory after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled(config.displayName, config.configPath); + return { + kind: "installed", + mutatedPath: config.configPath, + ...(backupPath !== undefined && { backupPath }), + }; + }, + }; +} diff --git a/src/cli/connect/kiro.ts b/src/cli/connect/kiro.ts new file mode 100644 index 0000000..487a9ff --- /dev/null +++ b/src/cli/connect/kiro.ts @@ -0,0 +1,16 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +// Kiro stores user-level MCP servers in ~/.kiro/settings/mcp.json. +// Schema follows the standard MCP envelope { mcpServers: { ... } }. +// Source: kiro.dev/docs/cli/mcp +export const adapter = createJsonMcpAdapter({ + name: "kiro", + displayName: "Kiro", + detectDir: join(homedir(), ".kiro"), + configPath: join(homedir(), ".kiro", "settings", "mcp.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.kiro/settings/mcp.json (user-level). Workspace overrides live in .kiro/settings/mcp.json.", +}); diff --git a/src/cli/connect/openclaw.ts b/src/cli/connect/openclaw.ts new file mode 100644 index 0000000..837e1f2 --- /dev/null +++ b/src/cli/connect/openclaw.ts @@ -0,0 +1,14 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +export const adapter = createJsonMcpAdapter({ + name: "openclaw", + displayName: "OpenClaw", + category: "native", + detectDir: join(homedir(), ".openclaw"), + configPath: join(homedir(), ".openclaw", "openclaw.json"), + docs: "https://github.com/rohitg00/agentmemory/tree/main/integrations/openclaw", + protocolNote: + "→ Using MCP. Hooks are also available — see https://github.com/rohitg00/agentmemory/tree/main/integrations/openclaw.", +}); diff --git a/src/cli/connect/opencode.ts b/src/cli/connect/opencode.ts new file mode 100644 index 0000000..76ddbf6 --- /dev/null +++ b/src/cli/connect/opencode.ts @@ -0,0 +1,108 @@ +import { existsSync, mkdirSync } 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, +} from "./util.js"; + +// OpenCode does not use the standard `mcpServers` block. Its config is a +// top-level `mcp` key whose entries carry `type`, `command` as an array, +// and `enabled` (docs: README "OpenCode (MCP only)"). So it needs its own +// adapter rather than createJsonMcpAdapter. + +const CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode.json"); +const DETECT_DIR = join(homedir(), ".config", "opencode"); + +// No `environment` block: OpenCode does not expand shell-style +// `${VAR:-default}` values, and writing them literally would override the +// user's real shell AGENTMEMORY_URL with an unexpanded string. The stdio +// child inherits the shell environment (an exported AGENTMEMORY_URL / +// AGENTMEMORY_SECRET still reaches the server), and the @agentmemory/mcp +// shim defaults unset vars (URL -> localhost:3111, no secret, all tools). +const OPENCODE_ENTRY = { + type: "local", + command: ["npx", "-y", "@agentmemory/mcp"], + enabled: true, +}; + +type OpencodeConfig = Record; +type McpEntry = Record; + +function entryMatches(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const command = (entry as McpEntry)["command"]; + return Array.isArray(command) && command.includes("@agentmemory/mcp"); +} + +export const adapter: ConnectAdapter = { + name: "opencode", + displayName: "OpenCode", + category: "mcp", + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "Using MCP via ~/.config/opencode/opencode.json (top-level `mcp` key). For full auto-capture, also install the bundled plugin in plugin/opencode/.", + + detect(): boolean { + return existsSync(DETECT_DIR); + }, + + async install(opts: ConnectOptions): Promise { + const existing = readJsonSafe(CONFIG_PATH); + const next: OpencodeConfig = existing ? { ...existing } : {}; + const existingMcp = next["mcp"]; + const mcp: Record = + existingMcp && + typeof existingMcp === "object" && + !Array.isArray(existingMcp) + ? { ...(existingMcp as Record) } + : {}; + + const alreadyHas = entryMatches(mcp["agentmemory"]); + if (alreadyHas && !opts.force) { + logAlreadyWired(this.displayName, CONFIG_PATH); + return { kind: "already-wired", mutatedPath: CONFIG_PATH }; + } + + if (opts.dryRun) { + p.log.info( + `[dry-run] Would ${alreadyHas ? "overwrite" : "add"} mcp.agentmemory in ${CONFIG_PATH}`, + ); + return { kind: "installed", mutatedPath: CONFIG_PATH }; + } + + let backupPath: string | undefined; + if (existsSync(CONFIG_PATH)) { + backupPath = backupFile(CONFIG_PATH, this.name); + logBackup(backupPath); + } else { + mkdirSync(dirname(CONFIG_PATH), { recursive: true }); + } + + mcp["agentmemory"] = { ...OPENCODE_ENTRY }; + next["mcp"] = mcp; + writeJsonAtomic(CONFIG_PATH, next); + + const verify = readJsonSafe(CONFIG_PATH); + const verifyMcp = verify?.["mcp"] as Record | undefined; + if (!entryMatches(verifyMcp?.["agentmemory"])) { + p.log.error( + `Verification failed: ${CONFIG_PATH} did not contain mcp.agentmemory after write.`, + ); + return { kind: "skipped", reason: "verification-failed" }; + } + + logInstalled(this.displayName, CONFIG_PATH); + return { + kind: "installed", + mutatedPath: CONFIG_PATH, + ...(backupPath !== undefined && { backupPath }), + }; + }, +}; diff --git a/src/cli/connect/openhuman.ts b/src/cli/connect/openhuman.ts new file mode 100644 index 0000000..8b9136f --- /dev/null +++ b/src/cli/connect/openhuman.ts @@ -0,0 +1,42 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; + +const OPENHUMAN_DIR = join(homedir(), ".openhuman"); +const DOCS = "https://github.com/tinyhumansai/openhuman"; + +export const adapter: ConnectAdapter = { + name: "openhuman", + displayName: "OpenHuman", + category: "native", + docs: DOCS, + protocolNote: + "→ Using native hooks (REST API at :3111). MCP not required.", + + detect(): boolean { + return existsSync(OPENHUMAN_DIR); + }, + + async install(_opts: ConnectOptions): Promise { + p.log.warn( + "OpenHuman integration is not yet automated. No `integrations/openhuman/` folder exists in the agentmemory repo today.", + ); + p.note( + [ + "OpenHuman is a Memory-trait host. The expected wiring is the REST", + "proxy at http://localhost:3111 plus an OpenHuman-side Memory trait", + "impl. Once integrations/openhuman/ lands in agentmemory we'll wire", + "this up automatically.", + "", + `Tracking: ${DOCS}`, + ].join("\n"), + "OpenHuman manual install", + ); + return { + kind: "stub", + reason: "no-integration-folder-yet", + }; + }, +}; diff --git a/src/cli/connect/pi.ts b/src/cli/connect/pi.ts new file mode 100644 index 0000000..3056d31 --- /dev/null +++ b/src/cli/connect/pi.ts @@ -0,0 +1,47 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import * as p from "@clack/prompts"; +import type { ConnectAdapter, ConnectOptions, ConnectResult } from "./types.js"; + +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"; + +export const adapter: ConnectAdapter = { + name: "pi", + displayName: "pi", + category: "native", + docs: DOCS, + protocolNote: + "→ Using native hooks (REST API at :3111). 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.", + ); + 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", + ); + return { + kind: "stub", + reason: "ts-extension-copy-not-implemented", + }; + }, +}; diff --git a/src/cli/connect/qwen.ts b/src/cli/connect/qwen.ts new file mode 100644 index 0000000..5329684 --- /dev/null +++ b/src/cli/connect/qwen.ts @@ -0,0 +1,17 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +// Qwen Code stores its settings (mcpServers + hooks) in +// ~/.qwen/settings.json. Schema for mcpServers matches Claude Code's +// shape, so the shared JSON adapter handles the wiring. +// Source: qwenlm.github.io/qwen-code-docs/en/users/features/mcp +export const adapter = createJsonMcpAdapter({ + name: "qwen", + displayName: "Qwen Code", + detectDir: join(homedir(), ".qwen"), + configPath: join(homedir(), ".qwen", "settings.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.qwen/settings.json. Qwen Code's hook system can also be wired separately — see docs.", +}); diff --git a/src/cli/connect/types.ts b/src/cli/connect/types.ts new file mode 100644 index 0000000..169dde1 --- /dev/null +++ b/src/cli/connect/types.ts @@ -0,0 +1,39 @@ +export type ConnectOptions = { + dryRun: boolean; + force: boolean; + /** + * When true, the Codex adapter additionally writes a global + * `~/.codex/hooks.json` block referencing absolute paths to bundled hook + * scripts. Workaround for openai/codex#16430, which prevents plugin-local + * hooks from dispatching on Codex Desktop. No-op for other adapters. + */ + withHooks?: boolean; +}; + +export type ConnectAdapter = { + name: string; + displayName: string; + docs?: string; + /** + * One-line explanation of which protocol this adapter wires (REST hooks vs + * MCP) and why. Printed above the install summary so users see — before + * any config mutation — that REST is the primary surface and MCP is the + * opt-in bridge for MCP-only clients. + */ + protocolNote?: string; + /** + * Integration style, used by onboarding to group agents. "native" = + * ships a first-party plugin / lifecycle hooks; "mcp" = wires the MCP + * server only. Declared on the adapter so the picker never needs a + * separate hardcoded list (#872). Defaults to "mcp" when omitted. + */ + category?: "native" | "mcp"; + detect(): boolean; + install(opts: ConnectOptions): Promise; +}; + +export type ConnectResult = + | { kind: "installed"; mutatedPath?: string; backupPath?: string } + | { kind: "already-wired"; mutatedPath?: string } + | { kind: "stub"; reason: string } + | { kind: "skipped"; reason: string }; diff --git a/src/cli/connect/util.ts b/src/cli/connect/util.ts new file mode 100644 index 0000000..580cd4e --- /dev/null +++ b/src/cli/connect/util.ts @@ -0,0 +1,109 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + copyFileSync, + renameSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import * as p from "@clack/prompts"; + +// Env values use ${VAR:-default} expansion so the wired MCP entry +// inherits AGENTMEMORY_URL / AGENTMEMORY_SECRET / AGENTMEMORY_TOOLS +// from the user's shell, but never fails parse when the var is unset +// (#510). Earlier `${VAR}` form caused Claude Code to silently drop the +// server when no shell-level export existed — per the Claude Code MCP +// docs, "If a required environment variable is not set and has no +// default value, Claude Code will fail to parse the config." +// +// Defaults match the documented runtime: localhost:3111 (no auth, all +// tools). One wired entry now serves local AND remote (Kubernetes / +// reverse-proxied) deployments without doctor-warning duplicates (#375) +// AND fresh installs that haven't exported envs (#510). +export const AGENTMEMORY_MCP_BLOCK = { + command: "npx", + args: ["-y", "@agentmemory/mcp"], + env: { + AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}", + AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}", + AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}", + }, +}; + +const COPILOT_MCP_COMMAND = + process.platform === "win32" + ? { + command: process.env["ComSpec"] || process.env["COMSPEC"] || "cmd.exe", + args: ["/d", "/s", "/c", "npx", "-y", "@agentmemory/mcp"], + } + : { + command: "npx", + args: ["-y", "@agentmemory/mcp"], + }; + +export const AGENTMEMORY_COPILOT_MCP_BLOCK = { + type: "local" as const, + ...COPILOT_MCP_COMMAND, + env: { + AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}", + AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}", + AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}", + }, + tools: ["*"], +}; + +export function backupsDir(): string { + return join(homedir(), ".agentmemory", "backups"); +} + +export function ensureBackupsDir(): string { + const dir = backupsDir(); + mkdirSync(dir, { recursive: true }); + return dir; +} + +export function timestampSlug(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +export function backupFile( + sourcePath: string, + agent: string, + ext = "json", +): string { + ensureBackupsDir(); + const stamp = timestampSlug(); + const target = join(backupsDir(), `${agent}-${stamp}.${ext}`); + copyFileSync(sourcePath, target); + return target; +} + +export function readJsonSafe(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as T; + } catch { + return null; + } +} + +export function writeJsonAtomic(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp-${process.pid}-${Date.now()}`; + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf-8"); + renameSync(tmp, path); +} + +export function logInstalled(label: string, target: string): void { + p.log.success(`${label} → wired into ${target}`); +} + +export function logAlreadyWired(label: string, target: string): void { + p.log.info(`${label} already wired in ${target} (use --force to re-install)`); +} + +export function logBackup(target: string): void { + p.log.info(`Backup: ${target}`); +} diff --git a/src/cli/connect/warp.ts b/src/cli/connect/warp.ts new file mode 100644 index 0000000..5ce87f5 --- /dev/null +++ b/src/cli/connect/warp.ts @@ -0,0 +1,19 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +// Warp stores MCP server config at ~/.warp/.mcp.json with the +// canonical `mcpServers` wrapper — identical schema to Claude Code. +// Warp also auto-discovers skills from .claude/skills/ so the +// agentmemory plugin's 8 skills are surfaced natively once the +// Claude Code plugin is installed. +// Source: docs.warp.dev/agent-platform/capabilities/mcp/ +export const adapter = createJsonMcpAdapter({ + name: "warp", + displayName: "Warp", + detectDir: join(homedir(), ".warp"), + configPath: join(homedir(), ".warp", ".mcp.json"), + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.warp/.mcp.json. Skills auto-discover from .claude/skills/ if the Claude Code plugin is also installed.", +}); diff --git a/src/cli/connect/zed.ts b/src/cli/connect/zed.ts new file mode 100644 index 0000000..bad7730 --- /dev/null +++ b/src/cli/connect/zed.ts @@ -0,0 +1,22 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { createJsonMcpAdapter } from "./json-mcp-adapter.js"; + +// Zed stores its settings (including MCP servers) under "context_servers" +// in settings.json — NOT "mcpServers". User config lives at +// ~/.config/zed/settings.json on all platforms (Zed uses the XDG path +// even on macOS; ~/Library/Application Support/Zed/ holds runtime data +// like the database + cached language servers, not the config). +// Source: zed.dev/docs/ai/mcp + zed.dev/docs/configuring-zed +const zedConfigDir = join(homedir(), ".config", "zed"); + +export const adapter = createJsonMcpAdapter({ + name: "zed", + displayName: "Zed", + detectDir: zedConfigDir, + configPath: join(zedConfigDir, "settings.json"), + wrapperKey: "context_servers", + docs: "https://github.com/rohitg00/agentmemory#other-agents", + protocolNote: + "→ Using MCP via ~/.config/zed/settings.json (key: context_servers).", +}); diff --git a/src/cli/doctor-diagnostics.ts b/src/cli/doctor-diagnostics.ts new file mode 100644 index 0000000..10d9513 --- /dev/null +++ b/src/cli/doctor-diagnostics.ts @@ -0,0 +1,379 @@ +// Doctor v2 diagnostic catalog. +// +// Each entry is a self-describing diagnostic: a check function that returns +// `{ ok, detail? }`, a human-readable message, an inline fix preview, and +// an `apply` function that runs the fix. The list is exported as a pure +// data structure so unit tests can assert on shape without bringing +// @clack/prompts into the test harness. +// +// The runtime (src/cli.ts -> runDoctor) iterates the list, prompts the user +// per check, and only re-runs the SAME diagnostic after a fix — never the +// whole suite. Each fix returns `{ ok, message? }` so we can show a one-line +// outcome before moving on. +// +// Doctor v2 surface: +// agentmemory doctor # interactive: Fix/Skip/More/Quit per failed check +// agentmemory doctor --all # apply every available fix without prompting (CI) +// agentmemory doctor --dry-run # show what each fix WOULD do; execute nothing + +export type DiagnosticStatus = { + ok: boolean; + /** Short status detail (one line). Shown alongside the check name. */ + detail?: string; +}; + +export type DiagnosticFixResult = { + ok: boolean; + message?: string; +}; + +export type DoctorContext = { + /** Base URL for the running engine, e.g. http://localhost:3111 */ + baseUrl: string; + /** Viewer URL, e.g. http://localhost:3113 */ + viewerUrl: string; + /** Path to ~/.agentmemory/.env */ + envPath: string; + /** Path to ~/.agentmemory/iii.pid */ + pidfilePath: string; + /** Path to ~/.agentmemory/engine-state.json */ + enginePath: string; + /** Pinned engine version (e.g. "0.11.2"). */ + pinnedVersion: string; +}; + +export type Diagnostic = { + /** Stable id. Used in --json and tests. */ + id: string; + /** One-line problem statement shown to the user. */ + message: string; + /** One-line description of WHAT the fix will do. Shown before the prompt. */ + fixPreview: string; + /** Longer explanation shown when the user picks [?] More info. */ + moreInfo: string; + /** Run the check; return ok=true if everything's fine, ok=false otherwise. */ + check: (ctx: DoctorContext) => Promise; + /** Apply the fix. Returns ok=true on success. */ + fix: (ctx: DoctorContext) => Promise; + /** True when there's nothing to auto-fix (we only suggest). */ + manualOnly?: boolean; +}; + +// Diagnostic ids are stable for testing and machine-readable doctor output. +export const DIAGNOSTIC_IDS = [ + "env-missing", + "no-llm-provider-key", + "engine-version-mismatch", + "viewer-unreachable", + "stale-pidfile", + "env-placeholder-keys", + "iii-on-path-not-local-bin", +] as const; + +export type DiagnosticId = (typeof DIAGNOSTIC_IDS)[number]; + +// Pure helpers (no I/O) — exported for direct unit testing. +// --------------------------------------------------------------------------- + +/** Common placeholder values shipped in .env.example. */ +const PLACEHOLDER_VALUES = new Set([ + "", + "your-key-here", + "sk-ant-...", + "sk-...", + "changeme", + "todo", + "xxx", +]); + +const PROVIDER_KEY_NAMES = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", + "MINIMAX_API_KEY", +] as const; + +export function parseEnvFile(content: string): Record { + const out: Record = {}; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + // Strip surrounding quotes. + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +/** Returns the list of provider keys that look real (non-placeholder). */ +export function realProviderKeys(env: Record): string[] { + return PROVIDER_KEY_NAMES.filter((k) => { + const v = (env[k] ?? "").trim(); + if (!v) return false; + if (PLACEHOLDER_VALUES.has(v.toLowerCase())) return false; + // Reject values that are just dots/placeholders like "xxxx-xxxx". + if (/^x+$/i.test(v.replace(/[-_]/g, ""))) return false; + return true; + }); +} + +/** Returns the list of provider key NAMES that exist but are placeholders. */ +export function placeholderProviderKeys(env: Record): string[] { + return PROVIDER_KEY_NAMES.filter((k) => { + const v = (env[k] ?? "").trim(); + if (!v) return false; + if (PLACEHOLDER_VALUES.has(v.toLowerCase())) return true; + if (/^x+$/i.test(v.replace(/[-_]/g, ""))) return true; + return false; + }); +} + +/** + * Build the canonical diagnostic catalog. + * + * The factory takes the side-effect helpers as injected functions so tests + * can swap them with stubs. Production callers pass real implementations + * from src/cli.ts. + */ +export type DoctorEffects = { + /** Does ~/.agentmemory/.env exist? */ + envFileExists: () => boolean; + /** Read ~/.agentmemory/.env and return parsed key=value pairs. */ + readEnvFile: () => Record; + /** Is the iii engine PID in the pidfile still alive? */ + pidfilePidIsAlive: () => boolean | null; + /** Does the pidfile exist on disk? */ + pidfileExists: () => boolean; + /** Resolve the iii binary on PATH; return null if not found. */ + findIiiBinary: () => string | null; + /** Path to ~/.agentmemory/bin/iii (the private install location). */ + localBinIiiPath: () => string; + /** Run `iii --version`; null if it fails. */ + iiiBinaryVersion: (binPath: string) => string | null; + /** Probe the viewer URL; true if it returns OK within timeoutMs. */ + viewerReachable: (timeoutMs?: number) => Promise; + /** Run init logic (copies .env.example). */ + runInit: () => Promise; + /** Open a file in $EDITOR (or fallback). Resolves when editor exits. */ + openEditor: (path: string) => Promise; + /** Run the iii installer. */ + runIiiInstaller: () => Promise; + /** Stop the running engine cleanly. */ + runStop: () => Promise; + /** Start the engine (waits for /livez). */ + runStart: () => Promise; + /** Clear pidfile + engine-state. */ + clearEnginePidAndState: () => void; +}; + +export function buildDiagnostics(effects: DoctorEffects): Diagnostic[] { + return [ + { + id: "env-missing", + message: "~/.agentmemory/.env is missing.", + fixPreview: "Copy .env.example into ~/.agentmemory/.env (your keys file).", + moreInfo: + "agentmemory reads provider API keys (Anthropic, OpenAI, Gemini, …) from ~/.agentmemory/.env. " + + "Without this file the daemon falls back to BM25-only search and no LLM-backed enrichment runs.", + check: async () => ({ + ok: effects.envFileExists(), + detail: effects.envFileExists() ? undefined : "no env file", + }), + fix: () => effects.runInit(), + }, + { + id: "no-llm-provider-key", + message: "No LLM provider API key found in ~/.agentmemory/.env.", + fixPreview: "Open ~/.agentmemory/.env in $EDITOR and paste your key, then re-check.", + moreInfo: + "Set at least one of: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, " + + "OPENROUTER_API_KEY, MINIMAX_API_KEY. The daemon picks the first that resolves " + + "to a real (non-placeholder) value at startup.", + check: async () => { + if (!effects.envFileExists()) { + return { ok: false, detail: "env file missing (run env-missing fix first)" }; + } + const env = effects.readEnvFile(); + const real = realProviderKeys(env); + return { + ok: real.length > 0, + detail: real.length > 0 ? `found: ${real.join(", ")}` : "no provider key set", + }; + }, + fix: (ctx) => effects.openEditor(ctx.envPath), + }, + { + id: "engine-version-mismatch", + message: "iii binary on PATH doesn't match the version agentmemory pins to.", + fixPreview: + "Re-run the iii installer for the pinned version and restart the engine.", + moreInfo: + "agentmemory pins the iii engine to a specific release because newer engines " + + "use a different worker model. Running a mismatched binary surfaces as EPIPE " + + "reconnect loops and empty search results.", + check: async (ctx) => { + const bin = effects.findIiiBinary(); + if (!bin) return { ok: false, detail: "iii not on PATH" }; + const v = effects.iiiBinaryVersion(bin); + if (!v) return { ok: false, detail: "iii on PATH but --version failed" }; + return { + ok: v === ctx.pinnedVersion, + detail: `${v} (pinned ${ctx.pinnedVersion})`, + }; + }, + fix: async () => { + const r = await effects.runIiiInstaller(); + if (!r.ok) return r; + // Best-effort restart: stop then start. + await effects.runStop(); + return effects.runStart(); + }, + }, + { + id: "viewer-unreachable", + message: "Viewer port not reachable.", + fixPreview: "Stop the engine, restart it, and retry the viewer probe.", + moreInfo: + "The viewer is served on REST port + 2 (default 3113). If it never came up " + + "the most common cause is port collision; a sibling PR ships auto-bump for " + + "this case. If that lands first this check just verifies; otherwise restart " + + "the engine to retry binding.", + check: async () => ({ + ok: await effects.viewerReachable(), + detail: undefined, + }), + fix: async () => { + const stopped = await effects.runStop(); + if (!stopped.ok) return stopped; + return effects.runStart(); + }, + }, + { + id: "stale-pidfile", + message: "Stale pidfile: pid recorded but the process is gone.", + fixPreview: "Clear ~/.agentmemory/iii.pid + engine-state.json, then restart.", + moreInfo: + "When the engine crashes hard (kill -9, OOM, host reboot) the pidfile sticks " + + "around. agentmemory refuses to start a second engine on top of a stale pid, " + + "so this state must be cleared explicitly.", + check: async () => { + if (!effects.pidfileExists()) return { ok: true, detail: "no pidfile" }; + const alive = effects.pidfilePidIsAlive(); + if (alive === null) return { ok: true, detail: "pidfile unreadable" }; + return { + ok: alive, + detail: alive ? "pid is alive" : "pid is gone", + }; + }, + fix: async () => { + effects.clearEnginePidAndState(); + return effects.runStart(); + }, + }, + { + id: "env-placeholder-keys", + message: "~/.agentmemory/.env contains placeholder/empty API keys.", + fixPreview: "Open ~/.agentmemory/.env in $EDITOR to paste real values.", + moreInfo: + "Lines like ANTHROPIC_API_KEY=sk-ant-... or =your-key-here are treated as " + + "absent. The daemon will fall back to BM25-only search. Replace placeholders " + + "with real keys or comment the line out.", + check: async () => { + if (!effects.envFileExists()) { + return { ok: true, detail: "env file missing (handled by env-missing)" }; + } + const env = effects.readEnvFile(); + const placeholders = placeholderProviderKeys(env); + return { + ok: placeholders.length === 0, + detail: + placeholders.length === 0 + ? undefined + : `placeholder: ${placeholders.join(", ")}`, + }; + }, + fix: (ctx) => effects.openEditor(ctx.envPath), + }, + { + id: "iii-on-path-not-local-bin", + message: + "iii is on PATH but not at agentmemory's private install path.", + fixPreview: + "Install the pinned version to ~/.agentmemory/bin — won't touch your PATH.", + moreInfo: + "agentmemory installs its pinned engine to ~/.agentmemory/bin/iii so a " + + "user-managed iii on PATH (homebrew, cargo, manual install) stays untouched. " + + "When agentmemory needs the pin and PATH doesn't have it, it falls back to the " + + "private install. If neither exists, run the installer.", + manualOnly: true, + check: async () => { + const bin = effects.findIiiBinary(); + if (!bin) return { ok: true, detail: "iii not on PATH (handled elsewhere)" }; + const localBin = effects.localBinIiiPath(); + return { + ok: bin === localBin, + detail: bin === localBin ? undefined : `iii at: ${bin}`, + }; + }, + fix: async () => + effects.runIiiInstaller().then((r) => ({ + ok: r.ok, + message: + r.message ?? + "Installer wrote to ~/.agentmemory/bin/iii. Your PATH wasn't modified.", + })), + }, + ]; +} + +export type DoctorRunMode = "interactive" | "all" | "dry-run"; + +/** + * Run all diagnostics and return their initial status (no fixes applied). + * Useful for tests and for `--dry-run` mode. + */ +export async function runAllChecks( + ctx: DoctorContext, + diagnostics: Diagnostic[], +): Promise> { + const results: Array<{ diagnostic: Diagnostic; status: DiagnosticStatus }> = []; + for (const d of diagnostics) { + const status = await d.check(ctx); + results.push({ diagnostic: d, status }); + } + return results; +} + +/** + * Dry-run output: each failing check's fix preview, prefixed by the diagnostic + * message. Pure function so we can snapshot-test the format. + */ +export function dryRunPlan( + ctx: DoctorContext, + results: Array<{ diagnostic: Diagnostic; status: DiagnosticStatus }>, +): string[] { + const lines: string[] = []; + let n = 0; + for (const { diagnostic, status } of results) { + if (status.ok) continue; + n++; + lines.push(`${n}. [${diagnostic.id}] ${diagnostic.message}`); + lines.push(` would fix: ${diagnostic.fixPreview}`); + if (status.detail) lines.push(` detail: ${status.detail}`); + } + if (lines.length === 0) { + lines.push(`All checks passing for ${ctx.baseUrl} — no fixes to run.`); + } + return lines; +} diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts new file mode 100644 index 0000000..6d04935 --- /dev/null +++ b/src/cli/onboarding.ts @@ -0,0 +1,373 @@ +// First-run interactive onboarding flow. +// +// Wakes up only when `isFirstRun()` is true (preferences are missing or +// have never recorded a `firstRunAt`) or when the user passes +// `--reset`. The flow asks for: +// +// 1. Which agents will be wired to agentmemory (multi-select). Each +// option carries a small glyph that we reuse in /status output so +// the user recognises them later. The label mirrors README row 1 +// (native plugins) and row 2 (MCP-only). +// 2. Which LLM provider to use for compress / consolidate / graph. +// "skip — BM25-only mode" is a real first-class option; lots of +// users want agentmemory purely as a hybrid keyword + vector +// memory layer without granting LLM API keys. +// +// We then write `~/.agentmemory/preferences.json` and seed +// `~/.agentmemory/.env` with a commented-out `*_API_KEY=` line for the +// chosen provider. This matches the existing `agentmemory init` flow +// closely so users who skip onboarding still get the same file via +// `agentmemory init`. + +import { copyFile, mkdir } from "node:fs/promises"; +import { constants as fsConstants, existsSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as p from "@clack/prompts"; +import { appendFileSync, readFileSync } from "node:fs"; +import { readPrefs, writePrefs } from "./preferences.js"; +import { ADAPTERS, resolveAdapter, runAdapter } from "./connect/index.js"; +import type { ConnectResult } from "./connect/types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Native plugin row — these agents ship an agentmemory plugin or +// first-party integration. Glyphs match SkillKit's published set +// where they overlap; the rest fall back to the generic `◇`. +// Display glyph per agent; the agent set itself comes from connect's +// ADAPTERS (single source of truth) so the picker can never drift from +// what `agentmemory connect` can actually wire (#872). Unknown adapters +// fall back to a neutral glyph. +const AGENT_GLYPH: Record = { + "claude-code": "⟁", + "copilot-cli": "◈", + codex: "◎", + cursor: "◫", + "gemini-cli": "✦", + opencode: "⬡", +}; + +const PROVIDERS: { value: string; label: string; envKey: string | null }[] = [ + { value: "anthropic", label: "Anthropic — claude", envKey: "ANTHROPIC_API_KEY" }, + { 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: "skip", label: "Skip — BM25-only mode (no LLM key)", envKey: null }, +]; + +const PROVIDER_COST_HINTS: Record = { + anthropic: "rough cost: a fast Haiku-class model keeps compress/consolidate at fractions of a cent per session.", + openai: "rough cost: a mini-class model keeps compress/consolidate at fractions of a cent per session.", + gemini: "rough cost: a Flash-class model keeps compress/consolidate at fractions of a cent per session.", + openrouter: "rough cost: pick a small model; spend tracks your chosen model's per-token price.", + minimax: "rough cost: scales with the MiniMax model price per token.", +}; + +export function buildAgentOptions(): { value: string; label: string; hint?: string }[] { + const options = ADAPTERS.map((a) => ({ + value: a.name, + label: `${AGENT_GLYPH[a.name] ?? "◇"} ${a.displayName}`, + hint: a.category === "native" ? "native plugin" : "MCP server", + })); + return [ + ...options.filter((o) => o.hint === "native plugin"), + ...options.filter((o) => o.hint === "MCP server"), + ]; +} + +export function getInitialAgentValues( + env: Record = process.env, +): string[] { + if (env["COPILOT_CLI"] === "1" || env["COPILOT_AGENT_SESSION_ID"]) { + return ["copilot-cli"]; + } + return ["claude-code"]; +} + +// Mirror src/cli.ts findEnvExample so onboarding ships the same .env +// skeleton whether called directly or via `agentmemory init`. We +// duplicate (rather than import) so the onboarding module doesn't +// pull cli.ts's top-level side effects into the test runner. +function findEnvExample(): string | null { + const candidates = [ + join(__dirname, "..", "..", ".env.example"), + join(__dirname, "..", ".env.example"), + join(__dirname, ".env.example"), + join(process.cwd(), ".env.example"), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +async function seedEnvFile(provider: string | null): Promise { + const target = join(homedir(), ".agentmemory", ".env"); + const dir = dirname(target); + await mkdir(dir, { recursive: true }); + + const template = findEnvExample(); + if (template && !existsSync(target)) { + try { + await copyFile(template, target, fsConstants.COPYFILE_EXCL); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") { + return null; + } + } + } else if (!template && !existsSync(target)) { + // Fall back to a minimal skeleton so users always get a `.env` to + // edit. This matches the shape of the bundled `.env.example` + // without forcing us to keep two copies in sync. + const lines = [ + "# agentmemory environment — uncomment what you need", + "# AGENTMEMORY_URL=http://localhost:3111", + "", + ]; + const envKey = PROVIDERS.find((x) => x.value === provider)?.envKey; + if (envKey) { + lines.push(`# ${envKey}=`); + } + writeFileSync(target, lines.join("\n"), { mode: 0o600 }); + } + + return target; +} + +export interface OnboardingResult { + agents: string[]; + provider: string | null; +} + +function shouldSkipInteractiveOnboarding(): boolean { + const ci = process.env["CI"]; + return ( + process.stdin.isTTY !== true || + process.stdout.isTTY !== true || + (ci !== undefined && ci !== "" && ci !== "0" && ci.toLowerCase() !== "false") + ); +} + +function writeDefaultOnboardingPrefs(): OnboardingResult { + writePrefs({ + lastAgent: null, + lastAgents: [], + lastProvider: null, + skipSplash: true, + firstRunAt: new Date().toISOString(), + }); + return { agents: [], provider: null }; +} + +export async function runOnboarding(): Promise { + if (shouldSkipInteractiveOnboarding()) { + return writeDefaultOnboardingPrefs(); + } + + p.note( + [ + "Welcome to agentmemory.", + "", + "Persistent memory for your AI coding agents. We'll pick which", + "agents to wire up and which provider (if any) handles compression", + "and consolidation. Either step can be changed later in ~/.agentmemory/.env.", + ].join("\n"), + "first-run setup", + ); + + const agentsPicked = await p.multiselect({ + message: "Which agents will use agentmemory? (space to toggle, enter to confirm)", + options: buildAgentOptions(), + required: false, + initialValues: getInitialAgentValues(), + }); + if (p.isCancel(agentsPicked)) { + p.cancel("Setup cancelled. Re-run any time with: agentmemory --reset"); + process.exit(0); + } + + const pickedAgentsList = (agentsPicked as string[]) ?? []; + if (pickedAgentsList.length > 0) { + p.note( + [ + "━ how this works ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + "All selected agents share the same memory at :3111.", + "A memory saved by Claude Code is visible to Copilot + Codex + Cursor instantly.", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", + ].join("\n"), + ); + } + + const providerPicked = await p.select({ + message: "Which LLM provider should agentmemory use for compress/consolidate?", + options: PROVIDERS.map(({ value, label }) => ({ value, label })), + initialValue: "anthropic", + }); + if (p.isCancel(providerPicked)) { + p.cancel("Setup cancelled. Re-run any time with: agentmemory --reset"); + process.exit(0); + } + + const provider = providerPicked === "skip" ? null : providerPicked; + const agents = (agentsPicked as string[]) ?? []; + + if (provider) { + const hint = PROVIDER_COST_HINTS[provider]; + if (hint) { + p.log.info(hint); + } + } + + const envPath = await seedEnvFile(provider); + + await maybePromptContextInjection(envPath); + + writePrefs({ + lastAgent: agents[0] ?? null, + lastAgents: agents, + lastProvider: provider, + skipSplash: true, + firstRunAt: new Date().toISOString(), + }); + + const prefsLocation = join(homedir(), ".agentmemory", "preferences.json"); + const lines = [`✓ Saved preferences to ${prefsLocation}`]; + if (envPath) { + lines.push(`✓ Wrote ${envPath} (edit to add your API key)`); + } else { + lines.push(`! Could not write ~/.agentmemory/.env — run \`agentmemory init\` after this completes.`); + } + if (provider) { + const envKey = PROVIDERS.find((x) => x.value === provider)?.envKey; + if (envKey) { + lines.push(` Uncomment ${envKey}= in that file to enable ${provider}.`); + } + } else { + lines.push(" No provider chosen — agentmemory will run in BM25-only mode."); + } + p.note(lines.join("\n"), "ready"); + + if (agents.length > 0) { + await wireSelectedAgents(agents); + } + + return { agents, provider }; +} + +function enableInjectContextInEnv(envPath: string | null): boolean { + if (!envPath || !existsSync(envPath)) return false; + try { + const current = readFileSync(envPath, "utf-8"); + if (/^\s*AGENTMEMORY_INJECT_CONTEXT\s*=\s*true\b/m.test(current)) { + return true; + } + const prefix = current.length > 0 && !current.endsWith("\n") ? "\n" : ""; + appendFileSync(envPath, `${prefix}AGENTMEMORY_INJECT_CONTEXT=true\n`, { mode: 0o600 }); + return true; + } catch { + return false; + } +} + +async function maybePromptContextInjection(envPath: string | null): Promise { + if (readPrefs().injectContextChosen) return; + + const enable = await p.confirm({ + message: "Enable automatic context injection so the agent recalls past sessions without being asked? [y/N]", + initialValue: false, + }); + + if (p.isCancel(enable)) { + p.cancel("Setup cancelled. Re-run any time with: agentmemory --reset"); + process.exit(0); + } + + p.log.info( + "Cost note: injection spends session tokens proportional to tool-call frequency. Default is off.", + ); + + writePrefs({ injectContextChosen: true }); + + if (enable === true) { + const wrote = enableInjectContextInEnv(envPath); + if (wrote) { + p.log.success("Context injection enabled (AGENTMEMORY_INJECT_CONTEXT=true)."); + } else { + p.log.warn( + "Could not update ~/.agentmemory/.env. Set AGENTMEMORY_INJECT_CONTEXT=true there to enable it.", + ); + } + } else { + p.log.info("Context injection left off. Set AGENTMEMORY_INJECT_CONTEXT=true later to enable."); + } +} + +async function wireSelectedAgents(agents: string[]): Promise { + p.note("Wire selected agents now?", "next step"); + const confirmed = await p.confirm({ + message: "Run `agentmemory connect ` for each selected agent now? [Y/n]", + initialValue: true, + }); + + if (p.isCancel(confirmed) || confirmed === false) { + const cmds = agents.map((a) => ` agentmemory connect ${a}`); + p.note(["Wire later with:", ...cmds].join("\n"), "later"); + return; + } + + const wired: string[] = []; + const manual: { name: string; docs?: string }[] = []; + const failed: { name: string; reason: string }[] = []; + + for (const name of agents) { + const adapter = resolveAdapter(name); + if (!adapter) { + failed.push({ name, reason: "no adapter available" }); + p.log.warn(`Wiring ${name}… no adapter available (skipped).`); + continue; + } + p.log.step(`Wiring ${name}...`); + let result: ConnectResult; + try { + result = await runAdapter(adapter, { dryRun: false, force: false }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + failed.push({ name, reason }); + p.log.error(`${name}: ${reason}`); + continue; + } + switch (result.kind) { + case "installed": + case "already-wired": + wired.push(name); + break; + case "stub": + manual.push({ name, docs: adapter.docs }); + break; + case "skipped": + failed.push({ name, reason: result.reason }); + break; + } + } + + const summary: string[] = []; + if (wired.length > 0) { + summary.push(`Wired: ${wired.join(", ")}.`); + } + if (manual.length > 0 || failed.length > 0) { + const parts: string[] = []; + for (const m of manual) { + parts.push(`${m.name} (manual install required${m.docs ? ` — see ${m.docs}` : ""})`); + } + for (const f of failed) { + parts.push(`${f.name} (${f.reason})`); + } + summary.push(`Skipped/failed: ${parts.join(", ")}.`); + } + if (summary.length === 0) { + summary.push("No agents were wired."); + } + p.note(summary.join("\n"), "wire summary"); +} diff --git a/src/cli/preferences.ts b/src/cli/preferences.ts new file mode 100644 index 0000000..63695db --- /dev/null +++ b/src/cli/preferences.ts @@ -0,0 +1,146 @@ +// JSON-backed CLI preferences. +// +// Lives at `~/.agentmemory/preferences.json`. The agentmemory daemon +// already owns `~/.agentmemory/.env`, `iii.pid`, `engine-state.json` — +// adding one more sibling here keeps the install-state surface in one +// place. +// +// All functions are synchronous, mirroring the pidfile / engine-state +// helpers in src/cli.ts. We never throw: read failures collapse to +// defaults; write failures swallow silently. Preferences are a UX +// nicety, not data — corrupting `iii.pid` matters, corrupting this +// file does not. +// +// Writes are atomic via tmp + rename so a Ctrl+C between the open and +// the final write can't leave a half-written JSON blob on disk that +// the next read would refuse to parse. + +import { + closeSync, + existsSync, + fsyncSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface Prefs { + schemaVersion: 1; + // Most recently picked single agent (for "use last agent" style flows). + lastAgent: string | null; + // The full multi-select set from the last onboarding run. + lastAgents: string[]; + // Most recently picked LLM provider; `null` means BM25-only mode. + lastProvider: string | null; + // Once true, splash is rendered only on first run / explicit --reset. + // The first onboarding sets this to true so the second invocation + // skips the banner. + skipSplash: boolean; + // Reserved for a later "do not nag me about the npx vs install + // tradeoff" toggle. Kept on the schema so we don't have to bump + // schemaVersion when we ship the flag. + skipNpxHint: boolean; + // Set to true when the user declines the "install agentmemory + // globally?" prompt on first npx run. We never ask again on this + // machine so the prompt stays a one-time DX nudge, not a nag. + skipGlobalInstall: boolean; + // Set to true when the user declines the "install iii console?" + // prompt. iii console is first-class engine UI but optional at the + // install step — once the user says no, we stop asking. + skipConsoleInstall: boolean; + // ISO timestamp of the first time onboarding completed. Set once, + // never updated, so we can show "you joined agentmemory N days ago" + // copy in /status later without keeping a separate file. + firstRunAt: string | null; + // Set to true once the user has answered the context-injection prompt + // (either way). We never re-ask after this so the prompt stays a + // one-time choice, matching the skipGlobalInstall / skipConsoleInstall + // never-nag pattern. + injectContextChosen: boolean; +} + +const DEFAULTS: Prefs = { + schemaVersion: 1, + lastAgent: null, + lastAgents: [], + lastProvider: null, + skipSplash: false, + skipNpxHint: false, + skipGlobalInstall: false, + skipConsoleInstall: false, + firstRunAt: null, + injectContextChosen: false, +}; + +export function prefsDir(): string { + return join(homedir(), ".agentmemory"); +} + +export function prefsPath(): string { + return join(prefsDir(), "preferences.json"); +} + +export function readPrefs(): Prefs { + try { + if (!existsSync(prefsPath())) return { ...DEFAULTS }; + const raw = readFileSync(prefsPath(), "utf-8"); + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULTS, ...parsed, schemaVersion: 1 }; + } catch { + return { ...DEFAULTS }; + } +} + +export function writePrefs(p: Partial): void { + try { + const dir = prefsDir(); + mkdirSync(dir, { recursive: true }); + const current = readPrefs(); + const next: Prefs = { ...current, ...p, schemaVersion: 1 }; + const target = prefsPath(); + const tmp = target + ".tmp"; + // Open + write + fsync + rename ensures a Ctrl+C between any two + // syscalls either leaves the old file intact (rename is atomic on + // POSIX) or leaves only a .tmp behind that the next writePrefs + // overwrites. We never end up with a truncated `preferences.json` + // that readPrefs would have to discard. + const fd = openSync(tmp, "w", 0o600); + try { + writeSync(fd, JSON.stringify(next, null, 2) + "\n"); + try { + fsyncSync(fd); + } catch { + // fsync isn't available on every filesystem (e.g. some Docker + // overlays). The rename below is still atomic; we just can't + // guarantee durability against a power loss. + } + } finally { + closeSync(fd); + } + renameSync(tmp, target); + } catch { + // Preferences are best-effort. Don't crash the CLI for them. + } +} + +export function resetPrefs(): void { + try { + unlinkSync(prefsPath()); + } catch { + // Already gone — that's exactly the state we wanted. + } +} + +export function isFirstRun(): boolean { + // "First run" means: the preferences file doesn't exist OR exists + // but `firstRunAt` was never recorded. The latter handles users who + // had `.agentmemory/preferences.json` from a much older agentmemory + // build that wrote a different schema — we treat them as new. + if (!existsSync(prefsPath())) return true; + return readPrefs().firstRunAt === null; +} diff --git a/src/cli/remove-plan.ts b/src/cli/remove-plan.ts new file mode 100644 index 0000000..fbc64de --- /dev/null +++ b/src/cli/remove-plan.ts @@ -0,0 +1,283 @@ +// `agentmemory remove` — destruction plan. +// +// Generating the plan is a pure function of the on-disk state (which files +// exist, whether ~/.local/bin/iii matches the version we installed, the +// connect-manifest contents). All side effects live in src/cli.ts; this +// module owns only the planning logic so it's unit-testable without +// touching $HOME. +// +// CLI surface: +// agentmemory remove # interactive, double-confirms +// agentmemory remove --force # skip confirmations +// agentmemory remove --keep-data # remove binaries+symlinks, keep memory data + +import { existsSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export type RemovePlanItem = { + /** Stable id, used in tests and CLI output. */ + id: string; + /** Human-readable description of the action. */ + description: string; + /** Absolute path being acted on (or null for non-fs actions). */ + path: string | null; + /** Whether this item is `ask-again` even with --force (e.g. memory data). */ + alwaysAsk: boolean; + /** Whether the file actually exists / action is meaningful. Plan-time hint. */ + applicable: boolean; + /** Bytes (for files) or -1 (unknown / dir). Pure metadata. */ + sizeBytes: number; +}; + +export type RemoveOptions = { + /** Skip confirmations (still asks separately about always-ask items). */ + force: boolean; + /** Keep ~/.agentmemory/* user data; only remove binaries/symlinks. */ + keepData: boolean; +}; + +export type RemoveContext = { + /** $HOME (so tests can sandbox). */ + home: string; + /** Pinned engine version we expect ~/.local/bin/iii to match. */ + pinnedVersion: string; + /** + * `iii --version` result for ~/.local/bin/iii, or null if it's missing / + * unreadable / not executable. Passed in so the plan module stays pure. + */ + localBinIiiVersion: string | null; + /** Loaded connect manifest, or null if missing. */ + connectManifest: ConnectManifest | null; +}; + +/** + * The `agentmemory connect` PR writes this manifest at + * ~/.agentmemory/backups/connect-manifest.json. We tolerate it being absent + * (older versions, fresh installs) by treating it as `{ installed: [] }`. + */ +export type ConnectManifest = { + installed: Array<{ + /** Target path the connect command wrote (symlink or file). */ + target: string; + /** Agent label, e.g. "claude-code", "cursor". */ + agent?: string; + /** Whether this was a symlink (true) or copy (false). */ + symlink?: boolean; + }>; +}; + +export function pidfilePath(home: string): string { + return join(home, ".agentmemory", "iii.pid"); +} + +export function enginePath(home: string): string { + return join(home, ".agentmemory", "engine-state.json"); +} + +export function envPath(home: string): string { + return join(home, ".agentmemory", ".env"); +} + +export function preferencesPath(home: string): string { + return join(home, ".agentmemory", "preferences.json"); +} + +export function backupsDir(home: string): string { + return join(home, ".agentmemory", "backups"); +} + +export function dataDir(home: string): string { + return join(home, ".agentmemory", "data"); +} + +// Platform-aware binary name. Windows requires the .exe suffix or the +// existsSync probe misses the installed binary. +function iiiBinFile(): string { + return process.platform === "win32" ? "iii.exe" : "iii"; +} + +// Legacy install location. Older agentmemory versions wrote the pinned iii +// engine here. Kept so `agentmemory remove` can still clean up after them. +export function legacyLocalBinIii(home: string): string { + return join(home, ".local", "bin", iiiBinFile()); +} + +// Current private install location. Lives under ~/.agentmemory/ so it +// stays isolated from any user-managed iii on PATH. +export function privateIiiBin(home: string): string { + return join(home, ".agentmemory", "bin", iiiBinFile()); +} + +// Back-compat shim for any caller still importing the old name. +export const localBinIii = privateIiiBin; + +function safeSize(path: string): number { + try { + return statSync(path).size; + } catch { + return -1; + } +} + +function pathExists(path: string): boolean { + try { + return existsSync(path); + } catch { + return false; + } +} + +/** + * Build the destruction plan for `agentmemory remove`. + * + * Plan items are returned regardless of whether `applicable` is true — the + * caller can decide whether to skip-and-log or hide entirely. This keeps + * the structure stable for tests. + */ +export function buildRemovePlan( + ctx: RemoveContext, + options: RemoveOptions, +): RemovePlanItem[] { + const { home, pinnedVersion, localBinIiiVersion, connectManifest } = ctx; + const plan: RemovePlanItem[] = []; + + plan.push({ + id: "stop-engine", + description: "Stop running iii-engine (if any) cleanly", + path: null, + alwaysAsk: false, + applicable: pathExists(pidfilePath(home)) || pathExists(enginePath(home)), + sizeBytes: -1, + }); + + plan.push({ + id: "pidfile", + description: "Delete pidfile", + path: pidfilePath(home), + alwaysAsk: false, + applicable: pathExists(pidfilePath(home)), + sizeBytes: safeSize(pidfilePath(home)), + }); + + plan.push({ + id: "engine-state", + description: "Delete engine-state.json", + path: enginePath(home), + alwaysAsk: false, + applicable: pathExists(enginePath(home)), + sizeBytes: safeSize(enginePath(home)), + }); + + // .env holds the user's API keys. Always ask before deleting, even on + // --force. --keep-data keeps it as part of "user data". + plan.push({ + id: "env", + description: "Delete .env (your API keys) — will ask separately", + path: envPath(home), + alwaysAsk: true, + applicable: !options.keepData && pathExists(envPath(home)), + sizeBytes: safeSize(envPath(home)), + }); + + plan.push({ + id: "preferences", + description: "Delete preferences.json", + path: preferencesPath(home), + alwaysAsk: false, + applicable: !options.keepData && pathExists(preferencesPath(home)), + sizeBytes: safeSize(preferencesPath(home)), + }); + + plan.push({ + id: "backups", + description: "Delete backups/ directory (connect manifest + backups)", + path: backupsDir(home), + alwaysAsk: false, + applicable: !options.keepData && pathExists(backupsDir(home)), + sizeBytes: -1, + }); + + // Iterate over connect-installed agent symlinks. We always honor these + // (even with --keep-data, since they're outside ~/.agentmemory/). + if (connectManifest?.installed?.length) { + for (const entry of connectManifest.installed) { + plan.push({ + id: `connect:${entry.target}`, + description: `Remove agent connection (${entry.agent ?? "unknown"})`, + path: entry.target, + alwaysAsk: false, + applicable: pathExists(entry.target), + sizeBytes: safeSize(entry.target), + }); + } + } + + // Private install (~/.agentmemory/bin/iii) — agentmemory owns this path, + // so it's always safe to remove. The version check still gates the + // legacy ~/.local/bin/iii path which may be a user-managed install we + // don't own. + const privIii = privateIiiBin(home); + if (pathExists(privIii)) { + plan.push({ + id: "private-bin-iii", + description: `Delete ~/.agentmemory/bin/iii (agentmemory's private install)`, + path: privIii, + alwaysAsk: false, + applicable: true, + sizeBytes: safeSize(privIii), + }); + } + + // Legacy ~/.local/bin/iii — only remove if it matches the version we + // installed. Older agentmemory wrote here; newer versions don't but the + // file may still be a leftover from a previous install. + // Heuristic: spawn `iii --version`; if it returns pinnedVersion, safe to + // remove. Otherwise mark `alwaysAsk` so the operator confirms explicitly. + const legacyIii = legacyLocalBinIii(home); + if (pathExists(legacyIii)) { + const matches = localBinIiiVersion === pinnedVersion; + plan.push({ + id: "legacy-local-bin-iii", + description: matches + ? `Delete ~/.local/bin/iii (legacy install location, matches pinned v${pinnedVersion})` + : `Delete ~/.local/bin/iii (legacy install location, version ${localBinIiiVersion ?? "unknown"} != pinned v${pinnedVersion}) — will ask`, + path: legacyIii, + alwaysAsk: !matches, + applicable: true, + sizeBytes: safeSize(legacyIii), + }); + } + + // Memory data dir — ALWAYS asks separately, even with --force. Default + // behavior is keep. + plan.push({ + id: "data-dir", + description: + "Delete memory data directory (~/.agentmemory/data/) — will ask separately", + path: dataDir(home), + alwaysAsk: true, + applicable: !options.keepData && pathExists(dataDir(home)), + sizeBytes: -1, + }); + + return plan; +} + +/** Format a plan for the user — one line per item. */ +export function formatPlan(plan: RemovePlanItem[]): string { + return plan + .filter((p) => p.applicable) + .map((p, i) => { + const tag = p.alwaysAsk ? " [asks]" : ""; + const sz = + p.sizeBytes > 0 ? ` (${humanBytes(p.sizeBytes)})` : ""; + return ` ${i + 1}. ${p.description}${tag}${sz}${p.path ? `\n ${p.path}` : ""}`; + }) + .join("\n"); +} + +function humanBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/cli/splash.ts b/src/cli/splash.ts new file mode 100644 index 0000000..ad18b2a --- /dev/null +++ b/src/cli/splash.ts @@ -0,0 +1,86 @@ +// Terminal-width-aware splash banner for the agentmemory CLI. +// +// Three render tiers, picked from `process.stdout.columns`: +// +// >= 120 cols: full block-art logo + tagline. +// 80–119 cols: compact monospace title + tagline. +// < 80 cols: single-line `agentmemory v`. +// +// The brand accent is the orange `#FF6B35` we already use in the README +// and viewer; we render it through ANSI 38;5;208 (the closest xterm-256 +// match) when stdout is a TTY, and fall back to plain text otherwise. +// No colour bytes are hard-coded into the strings themselves so that +// piping the banner to a file (`agentmemory > log`) stays clean. +// +// We don't pull in chalk/picocolors — picocolors is a transitive dep but +// we never want to depend on transitives directly. The two ANSI escape +// helpers below are the entire colour surface and they degrade to +// no-ops automatically. + +const IS_COLOR_TTY = !!process.stdout.isTTY && !process.env["NO_COLOR"]; + +function accent(s: string): string { + // 256-colour orange that visually matches #FF6B35 in most modern + // terminal palettes. We pick 208 (a true orange) over the closer-but- + // pinker 209 because it reads as the brand colour on both dark and + // light backgrounds. + return IS_COLOR_TTY ? `\x1b[38;5;208m${s}\x1b[0m` : s; +} + +function dim(s: string): string { + return IS_COLOR_TTY ? `\x1b[2m${s}\x1b[22m` : s; +} + +function bold(s: string): string { + return IS_COLOR_TTY ? `\x1b[1m${s}\x1b[22m` : s; +} + +function getTerminalWidth(): number { + const w = process.stdout.columns; + return typeof w === "number" && w > 0 ? w : 80; +} + +const TAGLINE = "Persistent memory for AI coding agents"; + +// "agentmemory" rendered in figlet's standard font (verified output — +// regenerate via `figlet agentmemory` if you change the wordmark). Each +// row is exactly 70 columns wide so the banner aligns cleanly inside +// the 2-col left margin we add below. +function fullBanner(version: string): string { + const logo = [ + " _ ", + " __ _ __ _ ___ _ __ | |_ _ __ ___ ___ _ __ ___ ___ _ __ _ _ ", + " / _` |/ _` |/ _ \\ '_ \\| __| '_ ` _ \\ / _ \\ '_ ` _ \\ / _ \\| '__| | | |", + "| (_| | (_| | __/ | | | |_| | | | | | __/ | | | | | (_) | | | |_| |", + " \\__,_|\\__, |\\___|_| |_|\\__|_| |_| |_|\\___|_| |_| |_|\\___/|_| \\__, |", + " |___/ |___/ ", + ]; + const lines: string[] = ["", ...logo.map((line) => " " + accent(line))]; + lines.push(""); + lines.push(" " + bold(TAGLINE) + " " + dim(`v${version}`)); + lines.push(""); + return lines.join("\n"); +} + +function compactBanner(version: string): string { + const title = " " + bold(accent("agentmemory")); + const meta = " " + dim(`v${version} · ${TAGLINE}`); + return ["", title, meta, ""].join("\n"); +} + +function minimalBanner(version: string): string { + return `${accent("agentmemory")} ${dim(`v${version}`)}`; +} + +export function renderSplash(version: string): void { + const width = getTerminalWidth(); + let out: string; + if (width >= 120) { + out = fullBanner(version); + } else if (width >= 80) { + out = compactBanner(version); + } else { + out = minimalBanner(version); + } + process.stdout.write(out + "\n"); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3348ac2 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,454 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import pc from "picocolors"; +import type { + AgentMemoryConfig, + ProviderConfig, + EmbeddingConfig, + FallbackConfig, + ClaudeBridgeConfig, + TeamConfig, +} from "./types.js"; + +function safeParseInt(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = parseInt(value, 10); + return Number.isNaN(parsed) ? fallback : parsed; +} + +const DATA_DIR = join(homedir(), ".agentmemory"); +const ENV_FILE = join(DATA_DIR, ".env"); + +let warnPremiumModelShown = false; + +function loadEnvFile(): Record { + if (!existsSync(ENV_FILE)) return {}; + const content = readFileSync(ENV_FILE, "utf-8"); + const vars: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + let val = trimmed.slice(eqIdx + 1).trim(); + const quoteChar = val[0] === '"' || val[0] === "'" ? val[0] : ""; + if (quoteChar) { + const closeIdx = val.indexOf(quoteChar, 1); + if (closeIdx !== -1) val = val.slice(1, closeIdx); + } else { + const hashIdx = val.indexOf(" #"); + if (hashIdx !== -1) val = val.slice(0, hashIdx).trim(); + } + vars[key] = val; + } + return vars; +} + +function hasRealValue(v: string | undefined): v is string { + return typeof v === "string" && v.trim().length > 0; +} + +function detectProvider(env: Record): ProviderConfig { + const maxTokens = parseInt(env["MAX_TOKENS"] || "4096", 10); + + // OpenAI-compatible: supports OpenAI, DeepSeek, SiliconFlow, Azure, vLLM, LM Studio + if (hasRealValue(env["OPENAI_API_KEY"]) && env["OPENAI_API_KEY_FOR_LLM"] !== "false") { + return { + provider: "openai", + model: env["OPENAI_MODEL"] || "gpt-4o-mini", + maxTokens, + baseURL: env["OPENAI_BASE_URL"], + }; + } + + // MiniMax: Anthropic-compatible API, requires raw fetch to avoid SDK stainless headers + if (hasRealValue(env["MINIMAX_API_KEY"])) { + return { + provider: "minimax", + model: env["MINIMAX_MODEL"] || "MiniMax-M2.7", + maxTokens, + }; + } + + if (hasRealValue(env["ANTHROPIC_API_KEY"])) { + return { + provider: "anthropic", + model: env["ANTHROPIC_MODEL"] || "claude-sonnet-4-20250514", + maxTokens, + baseURL: env["ANTHROPIC_BASE_URL"], + }; + } + if (hasRealValue(env["GEMINI_API_KEY"]) || hasRealValue(env["GOOGLE_API_KEY"])) { + if (!hasRealValue(env["GEMINI_API_KEY"]) && hasRealValue(env["GOOGLE_API_KEY"])) { + process.stderr.write( + "[agentmemory] GOOGLE_API_KEY detected — treating as GEMINI_API_KEY. " + + "Set GEMINI_API_KEY in ~/.agentmemory/.env to silence this warning.\n", + ); + } + return { + provider: "gemini", + model: env["GEMINI_MODEL"] || "gemini-2.5-flash", + maxTokens, + }; + } + if (hasRealValue(env["OPENROUTER_API_KEY"])) { + const model = + env["OPENROUTER_MODEL"] || "anthropic/claude-sonnet-4-20250514"; + // 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 + // ~$0.46/35h on deepseek-v4-pro for the same compression mix. + // Heuristic match avoids hard-coding a pricing table. + if ( + !warnPremiumModelShown && + /sonnet|opus|gpt-4o(?!.*mini)|gpt-4-turbo/i.test(model) && + env["AGENTMEMORY_SUPPRESS_COST_WARNING"] !== "1" && + env["AGENTMEMORY_SUPPRESS_COST_WARNING"] !== "true" + ) { + warnPremiumModelShown = true; + process.stderr.write( + `[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. ` + + `See README "Cost-aware model selection" for the full table. ` + + `Set AGENTMEMORY_SUPPRESS_COST_WARNING=1 to silence.\n`, + ); + } + return { + provider: "openrouter", + model, + maxTokens, + }; + } + + const allowAgentSdk = env["AGENTMEMORY_ALLOW_AGENT_SDK"] === "true"; + if (!allowAgentSdk) { + process.stderr.write( + pc.dim( + "[agentmemory] No LLM provider key set — running zero-LLM (BM25 + on-device embeddings). " + + "Set ANTHROPIC_API_KEY (or GEMINI/OPENAI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env for LLM compression and summaries. " + + "Agent-SDK fallback stays off by default to avoid a Stop-hook recursion loop; opt in with AGENTMEMORY_AUTO_COMPRESS=true + AGENTMEMORY_ALLOW_AGENT_SDK=true.\n", + ), + ); + return { + provider: "noop", + model: "noop", + maxTokens, + }; + } + + process.stderr.write( + "[agentmemory] WARNING: agent-sdk fallback enabled via AGENTMEMORY_ALLOW_AGENT_SDK=true. " + + "This spawns @anthropic-ai/claude-agent-sdk child sessions that can trigger the Stop-hook " + + "recursion loop. A SDK-child env marker is set to block re-entry, " + + "but prefer setting a real API key in ~/.agentmemory/.env instead.\n", + ); + return { + provider: "agent-sdk", + model: "claude-sonnet-4-20250514", + maxTokens, + }; +} + +export function loadConfig(): AgentMemoryConfig { + const env = getMergedEnv(); + + const provider = detectProvider(env); + + // Port quartet: REST is the anchor; streams/engine derive from it + // unless individually overridden. Default anchor 3111 yields the + // canonical 3112 streams / 49134 engine pair, but `III_REST_PORT=3211` + // auto-picks 3212 + 49234 so a second instance doesn't collide. + const restPort = parseInt(env["III_REST_PORT"] || "3111", 10) || 3111; + const streamsPort = + parseInt(env["III_STREAM_PORT"] || env["III_STREAMS_PORT"] || "", 10) || + restPort + 1; + const engineUrl = + env["III_ENGINE_URL"] || + `ws://localhost:${ + parseInt(env["III_ENGINE_PORT"] || "", 10) || restPort + 46023 + }`; + + return { + engineUrl, + restPort, + streamsPort, + provider, + tokenBudget: safeParseInt(env["TOKEN_BUDGET"], 2000), + maxObservationsPerSession: safeParseInt(env["MAX_OBS_PER_SESSION"], 500), + compressionModel: provider.model, + dataDir: DATA_DIR, + }; +} + +function getMergedEnv( + overrides?: Record, +): Record { + const fileEnv = loadEnvFile(); + return { ...fileEnv, ...process.env, ...overrides } as Record; +} + +export function getEnvVar(key: string): string | undefined { + return getMergedEnv()[key]; +} + +export function isDropStaleIndexEnabled(): boolean { + return getMergedEnv()["AGENTMEMORY_DROP_STALE_INDEX"] === "true"; +} + +export function detectLlmProviderKind(): "llm" | "noop" { + const env = getMergedEnv(); + if ( + hasRealValue(env["ANTHROPIC_API_KEY"]) || + hasRealValue(env["GEMINI_API_KEY"]) || + hasRealValue(env["GOOGLE_API_KEY"]) || + hasRealValue(env["OPENROUTER_API_KEY"]) || + hasRealValue(env["MINIMAX_API_KEY"]) || + (hasRealValue(env["OPENAI_API_KEY"]) && + env["OPENAI_API_KEY_FOR_LLM"] !== "false") + ) { + return "llm"; + } + return "noop"; +} + +export function loadEmbeddingConfig(): EmbeddingConfig { + const env = getMergedEnv(); + let bm25Weight = parseFloat(env["BM25_WEIGHT"] || "0.4"); + let vectorWeight = parseFloat(env["VECTOR_WEIGHT"] || "0.6"); + bm25Weight = + isNaN(bm25Weight) || bm25Weight < 0 ? 0.4 : Math.min(bm25Weight, 1); + vectorWeight = + isNaN(vectorWeight) || vectorWeight < 0 ? 0.6 : Math.min(vectorWeight, 1); + return { + provider: env["EMBEDDING_PROVIDER"] || undefined, + bm25Weight, + vectorWeight, + }; +} + +export function detectEmbeddingProvider( + env?: Record, +): string | null { + const source = env ?? getMergedEnv(); + const forced = source["EMBEDDING_PROVIDER"]; + if (forced) return forced; + + if (source["GEMINI_API_KEY"]) return "gemini"; + if (source["OPENAI_API_KEY"]) return "openai"; + if (source["VOYAGE_API_KEY"]) return "voyage"; + if (source["COHERE_API_KEY"]) return "cohere"; + if (source["OPENROUTER_API_KEY"]) return "openrouter"; + return null; +} + +export function loadClaudeBridgeConfig(): ClaudeBridgeConfig { + const env = getMergedEnv(); + const enabled = env["CLAUDE_MEMORY_BRIDGE"] === "true"; + const projectPath = env["CLAUDE_PROJECT_PATH"] || ""; + const lineBudget = safeParseInt(env["CLAUDE_MEMORY_LINE_BUDGET"], 200); + let memoryFilePath = ""; + if (enabled && projectPath) { + // Claude Code stores MEMORY.md at + // ~/.claude/projects//MEMORY.md + // where is the project path with `/` and `\` swapped for `-`. + // The leading `-` from an absolute POSIX path is preserved (Claude + // Code keeps it; stripping it produced a slug Claude never reads). + // There's also no `memory/` subdirectory — the file sits directly + // under the slug dir. + const safePath = projectPath.replace(/[/\\]/g, "-"); + memoryFilePath = join( + homedir(), + ".claude", + "projects", + safePath, + "MEMORY.md", + ); + } + return { enabled, projectPath, memoryFilePath, lineBudget }; +} + +export function loadTeamConfig(): TeamConfig | null { + const env = getMergedEnv(); + const teamId = env["TEAM_ID"]; + const userId = env["USER_ID"]; + if (!teamId || !userId) return null; + const mode = env["TEAM_MODE"] === "shared" ? "shared" : "private"; + return { teamId, userId, mode }; +} + +// optional AGENT_ID env for multi-agent memory isolation. +// Returns null when unset so memory stays unscoped (legacy behavior). +// Trimmed + length-capped to keep KV writes well-formed. +// +// Filtering is gated by AGENTMEMORY_AGENT_SCOPE: +// "shared" (default) — tag everything, do not filter recall paths +// "isolated" — tag everything AND filter recall paths +export function loadAgentScope(): { + agentId: string; + mode: "shared" | "isolated"; +} | null { + const env = getMergedEnv(); + const raw = env["AGENT_ID"]; + if (!raw) return null; + const agentId = raw.trim().slice(0, 128); + if (!agentId) return null; + const mode = env["AGENTMEMORY_AGENT_SCOPE"] === "isolated" + ? "isolated" + : "shared"; + return { agentId, mode }; +} + +export function getAgentId(): string | undefined { + return loadAgentScope()?.agentId; +} + +// True only when AGENT_ID is set AND scope=isolated. Recall paths +// consult this to decide whether to filter. +export function isAgentScopeIsolated(): boolean { + return loadAgentScope()?.mode === "isolated"; +} + +export function loadSnapshotConfig(): { + enabled: boolean; + interval: number; + dir: string; +} { + const env = getMergedEnv(); + return { + enabled: env["SNAPSHOT_ENABLED"] === "true", + interval: safeParseInt(env["SNAPSHOT_INTERVAL"], 3600), + dir: env["SNAPSHOT_DIR"] || join(homedir(), ".agentmemory", "snapshots"), + }; +} + +export function isGraphExtractionEnabled(): boolean { + return getMergedEnv()["GRAPH_EXTRACTION_ENABLED"] === "true"; +} + +export function getGraphBatchSize(): number { + return safeParseInt(getMergedEnv()["GRAPH_EXTRACTION_BATCH_SIZE"], 10); +} + +// window for the smart-search followup-rate diagnostic. A second +// search arriving within this many seconds (with disjoint results) +// counts as a "follow-up" — a directional signal that the first result +// set didn't satisfy. Long values overcount (legitimate refinement +// looks like a follow-up); short values undercount. +const FOLLOWUP_WINDOW_DEFAULT_SECONDS = 30; + +export function getFollowupWindowSeconds(): number { + return safeParseInt( + getMergedEnv()["AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS"], + FOLLOWUP_WINDOW_DEFAULT_SECONDS, + ); +} + +export function isConsolidationEnabled(): boolean { + const env = getMergedEnv(); + const explicit = env["CONSOLIDATION_ENABLED"]; + if (explicit === "false" || explicit === "0") return false; + if (explicit === "true" || explicit === "1") return true; + return hasLLMProviderConfigured(env); +} + +function hasLLMProviderConfigured(env: Record): boolean { + const provider = (env["AGENTMEMORY_PROVIDER"] || "").toLowerCase(); + if (provider === "noop") return false; + const openaiKeyForLlm = + env["OPENAI_API_KEY"] && + (env["OPENAI_API_KEY_FOR_LLM"] || "").toLowerCase() !== "false"; + return Boolean( + env["ANTHROPIC_API_KEY"] || + openaiKeyForLlm || + env["OPENROUTER_API_KEY"] || + env["GEMINI_API_KEY"] || + env["GOOGLE_API_KEY"] || + env["MINIMAX_API_KEY"] || + env["OPENAI_BASE_URL"] || + provider === "agent-sdk", + ); +} + +// Per-observation LLM compression is OFF by default as of 0.8.8. +// When disabled, observations are captured and indexed via a synthetic +// (zero-LLM) compression path so recall/search still works. Users who want +// richer LLM-generated summaries can set AGENTMEMORY_AUTO_COMPRESS=true in +// ~/.agentmemory/.env — but should expect their Claude API token usage to +// climb proportionally with session tool-use frequency. +export function isAutoCompressEnabled(): boolean { + return getMergedEnv()["AGENTMEMORY_AUTO_COMPRESS"] === "true"; +} + +// Hook-level context injection into Claude Code's conversation is OFF by +// default as of 0.8.10. When disabled, pre-tool-use and +// session-start hooks still POST observations for background capture, but +// never write context to stdout — so Claude Code doesn't inject an extra +// ~4000-char blob into every tool turn. 0.8.8 stopped the agentmemory-side +// Claude calls (via ANTHROPIC_API_KEY); this stops the Claude Code-side +// token burn where every tool call silently grew the model input window. +// Users who want the in-conversation context injection explicitly opt in +// with AGENTMEMORY_INJECT_CONTEXT=true and get a loud startup warning. +export function isContextInjectionEnabled(): boolean { + return getMergedEnv()["AGENTMEMORY_INJECT_CONTEXT"] === "true"; +} + +export function getConsolidationDecayDays(): number { + return safeParseInt(getMergedEnv()["CONSOLIDATION_DECAY_DAYS"], 30); +} + +export function isStandaloneMcp(): boolean { + return getMergedEnv()["STANDALONE_MCP"] === "true"; +} + +export function getStandalonePersistPath(): string { + const env = getMergedEnv(); + return ( + env["STANDALONE_PERSIST_PATH"] || + join(homedir(), ".agentmemory", "standalone.json") + ); +} + +const VALID_PROVIDERS = new Set([ + "anthropic", + "gemini", + "openrouter", + "agent-sdk", + "minimax", + "openai", +]); + +export function loadFallbackConfig(): FallbackConfig { + const env = getMergedEnv(); + const raw = env["FALLBACK_PROVIDERS"] || ""; + const allowAgentSdk = env["AGENTMEMORY_ALLOW_AGENT_SDK"] === "true"; + const providers = raw + .split(",") + .map((p) => p.trim()) + .filter( + (p): p is FallbackConfig["providers"][number] => + Boolean(p) && VALID_PROVIDERS.has(p), + ) + .filter((p) => { + // Honor the same safety gate as detectProvider: agent-sdk is only + // permitted as a fallback target when the user has explicitly opted + // in. Without this filter, a user could set FALLBACK_PROVIDERS=agent-sdk + // and re-introduce the Stop-hook recursion loop even though + // detectProvider() returned the noop provider. + if (p === "agent-sdk" && !allowAgentSdk) { + process.stderr.write( + "[agentmemory] Ignoring FALLBACK_PROVIDERS entry 'agent-sdk' " + + "(AGENTMEMORY_ALLOW_AGENT_SDK is not 'true'). The agent-sdk " + + "fallback can spawn Claude Agent SDK child sessions that trigger " + + "the Stop-hook recursion loop. Opt in explicitly " + + "with AGENTMEMORY_ALLOW_AGENT_SDK=true if this is intentional.\n", + ); + return false; + } + return true; + }); + return { providers }; +} diff --git a/src/eval/metrics-store.ts b/src/eval/metrics-store.ts new file mode 100644 index 0000000..1d381ff --- /dev/null +++ b/src/eval/metrics-store.ts @@ -0,0 +1,65 @@ +import type { FunctionMetrics } from "../types.js"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; + +export class MetricsStore { + private cache = new Map(); + private qualityCallCounts = new Map(); + + constructor(private kv: StateKV) {} + + async record( + functionId: string, + latencyMs: number, + success: boolean, + qualityScore?: number, + ): Promise { + let m = this.cache.get(functionId); + if (!m) { + m = (await this.kv.get(KV.metrics, functionId)) ?? { + functionId, + totalCalls: 0, + successCount: 0, + failureCount: 0, + avgLatencyMs: 0, + avgQualityScore: 0, + }; + } + + const prev = m.totalCalls; + m.totalCalls += 1; + m.avgLatencyMs = (m.avgLatencyMs * prev + latencyMs) / m.totalCalls; + if (success) { + m.successCount += 1; + } else { + m.failureCount += 1; + } + if (qualityScore !== undefined) { + const prevQualityCalls = this.qualityCallCounts.get(functionId) || 0; + m.avgQualityScore = + (m.avgQualityScore * prevQualityCalls + qualityScore) / + (prevQualityCalls + 1); + this.qualityCallCounts.set(functionId, prevQualityCalls + 1); + } + + this.cache.set(functionId, m); + await this.kv.set(KV.metrics, functionId, m).catch(() => {}); + } + + async get(functionId: string): Promise { + return ( + this.cache.get(functionId) ?? + (await this.kv.get(KV.metrics, functionId)) + ); + } + + async getAll(): Promise { + const kvMetrics = await this.kv + .list(KV.metrics) + .catch(() => []); + const merged = new Map(); + for (const m of kvMetrics) merged.set(m.functionId, m); + for (const [id, m] of this.cache) merged.set(id, m); + return Array.from(merged.values()); + } +} diff --git a/src/eval/quality.ts b/src/eval/quality.ts new file mode 100644 index 0000000..9f565d5 --- /dev/null +++ b/src/eval/quality.ts @@ -0,0 +1,51 @@ +export function scoreCompression(obs: { + type?: string; + title?: string; + facts?: string[]; + narrative?: string; + concepts?: string[]; + importance?: number; +}): number { + let score = 0; + if (obs.facts && obs.facts.length > 0) score += 25; + if (obs.facts && obs.facts.length >= 3) score += 10; + if (obs.narrative && obs.narrative.length >= 20) score += 20; + if (obs.narrative && obs.narrative.length >= 50) score += 5; + if (obs.title && obs.title.length >= 5 && obs.title.length <= 120) score += 15; + if (obs.concepts && obs.concepts.length > 0) score += 15; + if (obs.importance && obs.importance >= 1 && obs.importance <= 10) score += 10; + return Math.min(100, score); +} + +export function scoreSummary(summary: { + title?: string; + narrative?: string; + keyDecisions?: string[]; + filesModified?: string[]; + concepts?: string[]; +}): number { + let score = 0; + if (summary.title && summary.title.length >= 5) score += 20; + if (summary.narrative && summary.narrative.length >= 20) score += 25; + if (summary.narrative && summary.narrative.length >= 100) score += 5; + if (summary.keyDecisions && summary.keyDecisions.length > 0) score += 20; + if (summary.filesModified && summary.filesModified.length > 0) score += 15; + if (summary.concepts && summary.concepts.length > 0) score += 15; + return Math.min(100, score); +} + +export function scoreContextRelevance( + context: string, + project: string, +): number { + let score = 0; + if (context.length > 0) score += 20; + if (project && context.toLowerCase().includes(project.toLowerCase())) score += 20; + if (context.includes("<")) score += 15; + const sectionCount = (context.match(/<\w+>/g) || []).length; + if (sectionCount >= 2) score += 15; + if (sectionCount >= 4) score += 10; + if (context.length >= 100) score += 10; + if (context.length >= 500) score += 10; + return Math.min(100, score); +} diff --git a/src/eval/schemas.ts b/src/eval/schemas.ts new file mode 100644 index 0000000..bf5bfca --- /dev/null +++ b/src/eval/schemas.ts @@ -0,0 +1,124 @@ +import { z } from "zod"; + +const HookTypeEnum = z.enum([ + "session_start", + "prompt_submit", + "pre_tool_use", + "post_tool_use", + "post_tool_failure", + "pre_compact", + "subagent_start", + "subagent_stop", + "notification", + "task_completed", + "stop", + "session_end", +]); + +const ObservationTypeEnum = z.enum([ + "file_read", + "file_write", + "file_edit", + "command_run", + "search", + "web_fetch", + "conversation", + "error", + "decision", + "discovery", + "subagent", + "notification", + "task", + "other", +]); + +export const ObserveInputSchema = z.object({ + hookType: HookTypeEnum, + sessionId: z.string().min(1), + project: z.string().min(1), + cwd: z.string().min(1), + timestamp: z.string().min(1), + data: z.unknown(), +}); + +export const CompressOutputSchema = z.object({ + type: ObservationTypeEnum, + title: z.string().min(1).max(120), + subtitle: z.string().optional(), + facts: z.array(z.string()).min(1), + narrative: z.string().min(10), + concepts: z.array(z.string()), + files: z.array(z.string()), + importance: z.number().int().min(1).max(10), +}); + +export const SummaryOutputSchema = z.object({ + title: z.string().min(1), + narrative: z.string().min(20), + keyDecisions: z.array(z.string()), + filesModified: z.array(z.string()), + concepts: z.array(z.string()), +}); + +export const SearchInputSchema = z.object({ + query: z.string().min(1), + limit: z.number().int().positive().optional(), +}); + +export const ContextInputSchema = z.object({ + sessionId: z.string().min(1), + project: z.string().min(1), + budget: z.number().positive().optional(), +}); + +export const RememberInputSchema = z.object({ + content: z.string().min(1), + type: z + .enum(["pattern", "preference", "architecture", "bug", "workflow", "fact"]) + .optional(), + concepts: z.array(z.string()).optional(), + files: z.array(z.string()).optional(), +}); + +export const SmartSearchInputSchema = z.object({ + query: z.string().optional(), + expandIds: z.array(z.string()).optional(), + limit: z.number().int().positive().optional(), +}); + +export const TimelineInputSchema = z.object({ + anchor: z.string().min(1), + project: z.string().optional(), + before: z.number().int().nonnegative().optional(), + after: z.number().int().nonnegative().optional(), +}); + +export const ProfileInputSchema = z.object({ + project: z.string().min(1), + refresh: z.boolean().optional(), +}); + +export const RelateInputSchema = z.object({ + sourceId: z.string().min(1), + targetId: z.string().min(1), + type: z.enum(["supersedes", "extends", "derives", "contradicts", "related"]), +}); + +export const EvolveInputSchema = z.object({ + memoryId: z.string().min(1), + newContent: z.string().min(1), + newTitle: z.string().optional(), +}); + +export const ExportImportInputSchema = z.object({ + exportData: z.object({ + version: z.union([z.literal("0.3.0"), z.literal("0.4.0")]), + exportedAt: z.string(), + sessions: z.array(z.unknown()), + observations: z.record(z.string(), z.array(z.unknown())), + memories: z.array(z.unknown()), + summaries: z.array(z.unknown()), + profiles: z.array(z.unknown()).optional(), + }), + strategy: z.enum(["merge", "replace", "skip"]).optional(), +}); diff --git a/src/eval/self-correct.ts b/src/eval/self-correct.ts new file mode 100644 index 0000000..68fc34d --- /dev/null +++ b/src/eval/self-correct.ts @@ -0,0 +1,28 @@ +import type { MemoryProvider } from "../types.js"; + +const STRICTER_SUFFIX = ` + +IMPORTANT: Your previous response was invalid. Please ensure your output strictly follows the required XML format. Every required field must be present with valid values.`; + +export async function compressWithRetry( + provider: MemoryProvider, + systemPrompt: string, + userPrompt: string, + validator: (response: string) => { valid: boolean; errors?: string[] }, + maxRetries = 1, +): Promise<{ response: string; retried: boolean }> { + const first = await provider.compress(systemPrompt, userPrompt); + const result = validator(first); + if (result.valid) return { response: first, retried: false }; + + for (let i = 0; i < maxRetries; i++) { + const retry = await provider.compress( + systemPrompt + STRICTER_SUFFIX, + userPrompt, + ); + const retryResult = validator(retry); + if (retryResult.valid) return { response: retry, retried: true }; + } + + return { response: first, retried: true }; +} diff --git a/src/eval/validator.ts b/src/eval/validator.ts new file mode 100644 index 0000000..21332ce --- /dev/null +++ b/src/eval/validator.ts @@ -0,0 +1,31 @@ +import type { z } from "zod"; +import type { EvalResult } from "../types.js"; + +export function validateInput( + schema: z.ZodType, + data: unknown, + functionId: string, +): { valid: true; data: T } | { valid: false; result: EvalResult } { + const parsed = schema.safeParse(data); + if (parsed.success) { + return { valid: true, data: parsed.data }; + } + return { + valid: false, + result: { + valid: false, + errors: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`), + qualityScore: 0, + latencyMs: 0, + functionId, + }, + }; +} + +export function validateOutput( + schema: z.ZodType, + data: unknown, + functionId: string, +): { valid: true; data: T } | { valid: false; result: EvalResult } { + return validateInput(schema, data, functionId); +} diff --git a/src/functions/access-tracker.ts b/src/functions/access-tracker.ts new file mode 100644 index 0000000..6791409 --- /dev/null +++ b/src/functions/access-tracker.ts @@ -0,0 +1,104 @@ +import { KV } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { logger } from "../logger.js"; + +const RECENT_CAP = 20; + +export interface AccessLog { + memoryId: string; + count: number; + lastAt: string; + recent: number[]; +} + +export function emptyAccessLog(memoryId: string): AccessLog { + return { memoryId, count: 0, lastAt: "", recent: [] }; +} + +export function normalizeAccessLog(raw: unknown): AccessLog { + const r = (raw ?? {}) as Partial; + const rawCount = + typeof r.count === "number" && Number.isFinite(r.count) ? r.count : 0; + const count = Math.max(0, Math.floor(rawCount)); + const rawRecent = Array.isArray(r.recent) + ? r.recent.filter( + (x): x is number => typeof x === "number" && Number.isFinite(x), + ) + : []; + const recent = + rawRecent.length > RECENT_CAP ? rawRecent.slice(-RECENT_CAP) : rawRecent; + return { + memoryId: typeof r.memoryId === "string" ? r.memoryId : "", + count: Math.max(count, recent.length), + lastAt: typeof r.lastAt === "string" ? r.lastAt : "", + recent, + }; +} + +export async function getAccessLog( + kv: StateKV, + memoryId: string, +): Promise { + try { + const raw = await kv.get(KV.accessLog, memoryId); + if (!raw) return emptyAccessLog(memoryId); + const normalized = normalizeAccessLog(raw); + if (!normalized.memoryId) normalized.memoryId = memoryId; + return normalized; + } catch { + return emptyAccessLog(memoryId); + } +} + +export async function recordAccess( + kv: StateKV, + memoryId: string, + timestampMs?: number, +): Promise { + if (!memoryId) return; + const ts = timestampMs ?? Date.now(); + try { + await withKeyedLock(`mem:access:${memoryId}`, async () => { + const existing = await getAccessLog(kv, memoryId); + existing.count += 1; + existing.lastAt = new Date(ts).toISOString(); + existing.recent.push(ts); + if (existing.recent.length > RECENT_CAP) { + existing.recent = existing.recent.slice(-RECENT_CAP); + } + await kv.set(KV.accessLog, memoryId, existing); + }); + } catch (err) { + try { + logger.warn("recordAccess failed", { + memoryId, + error: err instanceof Error ? err.message : String(err), + }); + } catch {} + } +} + +export async function recordAccessBatch( + kv: StateKV, + memoryIds: string[], + timestampMs?: number, +): Promise { + if (!memoryIds || memoryIds.length === 0) return; + const ts = timestampMs ?? Date.now(); + const unique = Array.from(new Set(memoryIds.filter(Boolean))); + await Promise.allSettled(unique.map((id) => recordAccess(kv, id, ts))); +} + +export async function deleteAccessLog( + kv: StateKV, + memoryId: string, +): Promise { + if (!memoryId) return; + try { + await withKeyedLock(`mem:access:${memoryId}`, async () => { + await kv.delete(KV.accessLog, memoryId); + }); + } catch {} +} + diff --git a/src/functions/actions.ts b/src/functions/actions.ts new file mode 100644 index 0000000..12d12df --- /dev/null +++ b/src/functions/actions.ts @@ -0,0 +1,299 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import type { Action, ActionEdge } from "../types.js"; +import { recordAudit } from "./audit.js"; + +export function registerActionsFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::action-create", + async (data: { + title: string; + description?: string; + priority?: number; + createdBy?: string; + project?: string; + tags?: string[]; + parentId?: string; + sourceObservationIds?: string[]; + sourceMemoryIds?: string[]; + edges?: Array<{ type: string; targetActionId: string }>; + }) => { + if (!data.title || typeof data.title !== "string") { + return { success: false, error: "title is required" }; + } + + return withKeyedLock("mem:actions", async () => { + const now = new Date().toISOString(); + const action: Action = { + id: generateId("act"), + title: data.title.trim(), + description: (data.description || "").trim(), + status: "pending", + priority: Math.max(1, Math.min(10, data.priority || 5)), + createdAt: now, + updatedAt: now, + createdBy: data.createdBy || "unknown", + project: data.project, + tags: data.tags || [], + sourceObservationIds: data.sourceObservationIds || [], + sourceMemoryIds: data.sourceMemoryIds || [], + parentId: data.parentId, + }; + + if (data.parentId) { + const parent = await kv.get(KV.actions, data.parentId); + if (!parent) { + return { success: false, error: "parent action not found" }; + } + } + + const validEdgeTypes = [ + "requires", + "unlocks", + "spawned_by", + "gated_by", + "conflicts_with", + ]; + const pendingEdges: ActionEdge[] = []; + let hasRequires = false; + if (data.edges && Array.isArray(data.edges)) { + for (const e of data.edges) { + if (!validEdgeTypes.includes(e.type)) { + return { success: false, error: `invalid edge type: ${e.type}` }; + } + const targetAction = await kv.get(KV.actions, e.targetActionId); + if (!targetAction) { + return { success: false, error: `target action not found: ${e.targetActionId}` }; + } + if (e.type === "requires") hasRequires = true; + pendingEdges.push({ + id: generateId("ae"), + type: e.type as ActionEdge["type"], + sourceActionId: action.id, + targetActionId: e.targetActionId, + createdAt: now, + }); + } + } + + if (hasRequires) { + action.status = "blocked"; + } + + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "action_create", "mem::action-create", [action.id], { + actor: data.createdBy || "unknown", + action, + edges: pendingEdges, + }); + + for (const edge of pendingEdges) { + await kv.set(KV.actionEdges, edge.id, edge); + } + + return { success: true, action, edges: pendingEdges }; + }); + }, + ); + + sdk.registerFunction("mem::action-update", + async (data: { + actionId: string; + status?: Action["status"]; + title?: string; + description?: string; + priority?: number; + assignedTo?: string; + result?: string; + tags?: string[]; + }) => { + if (!data.actionId) { + return { success: false, error: "actionId is required" }; + } + + return withKeyedLock(`mem:action:${data.actionId}`, async () => { + const action = await kv.get(KV.actions, data.actionId); + if (!action) { + return { success: false, error: "action not found" }; + } + const before = { ...action }; + + if (data.status !== undefined) action.status = data.status; + if (data.title !== undefined) action.title = data.title.trim(); + if (data.description !== undefined) + action.description = data.description.trim(); + if (data.priority !== undefined) + action.priority = Math.max(1, Math.min(10, data.priority)); + if (data.assignedTo !== undefined) action.assignedTo = data.assignedTo; + if (data.result !== undefined) action.result = data.result; + if (data.tags !== undefined) action.tags = data.tags; + action.updatedAt = new Date().toISOString(); + + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "action_update", "mem::action-update", [action.id], { + actor: data.assignedTo || "unknown", + before, + after: action, + }); + + if (data.status === "done") { + await propagateCompletion(kv, action.id); + } + + return { success: true, action }; + }); + }, + ); + + sdk.registerFunction("mem::action-edge-create", + async (data: { + sourceActionId: string; + targetActionId: string; + type: string; + metadata?: Record; + }) => { + if (!data.sourceActionId || !data.targetActionId || !data.type) { + return { + success: false, + error: "sourceActionId, targetActionId, and type are required", + }; + } + + const validTypes = [ + "requires", + "unlocks", + "spawned_by", + "gated_by", + "conflicts_with", + ]; + if (!validTypes.includes(data.type)) { + return { + success: false, + error: `type must be one of: ${validTypes.join(", ")}`, + }; + } + + const sourceAction = await kv.get(KV.actions, data.sourceActionId); + if (!sourceAction) { + return { success: false, error: "source action not found" }; + } + const targetAction = await kv.get(KV.actions, data.targetActionId); + if (!targetAction) { + return { success: false, error: "target action not found" }; + } + + const edge: ActionEdge = { + id: generateId("ae"), + type: data.type as ActionEdge["type"], + sourceActionId: data.sourceActionId, + targetActionId: data.targetActionId, + createdAt: new Date().toISOString(), + metadata: data.metadata, + }; + + await kv.set(KV.actionEdges, edge.id, edge); + await recordAudit(kv, "action_create", "mem::action-edge-create", [edge.id], { + actor: "unknown", + edge, + }); + return { success: true, edge }; + }, + ); + + sdk.registerFunction("mem::action-list", + async (data: { + status?: string; + project?: string; + parentId?: string; + tags?: string[]; + limit?: number; + }) => { + let actions = await kv.list(KV.actions); + + if (data.status) { + actions = actions.filter((a) => a.status === data.status); + } + if (data.project) { + actions = actions.filter((a) => a.project === data.project); + } + if (data.parentId) { + actions = actions.filter((a) => a.parentId === data.parentId); + } + if (data.tags && data.tags.length > 0) { + actions = actions.filter((a) => + data.tags!.some((t) => a.tags.includes(t)), + ); + } + + actions.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + const limit = data.limit || 50; + return { success: true, actions: actions.slice(0, limit) }; + }, + ); + + sdk.registerFunction("mem::action-get", + async (data: { actionId: string }) => { + if (!data.actionId) { + return { success: false, error: "actionId is required" }; + } + const action = await kv.get(KV.actions, data.actionId); + if (!action) { + return { success: false, error: "action not found" }; + } + + const allEdges = await kv.list(KV.actionEdges); + const edges = allEdges.filter( + (e) => + e.sourceActionId === data.actionId || + e.targetActionId === data.actionId, + ); + + const children = (await kv.list(KV.actions)).filter( + (a) => a.parentId === data.actionId, + ); + + return { success: true, action, edges, children }; + }, + ); +} + +async function propagateCompletion( + kv: StateKV, + completedActionId: string, +): Promise { + const allEdges = await kv.list(KV.actionEdges); + const unlockEdges = allEdges.filter( + (e) => + e.targetActionId === completedActionId && + (e.type === "requires" || e.type === "unlocks"), + ); + + const allActions = await kv.list(KV.actions); + const actionMap = new Map(allActions.map((a) => [a.id, a])); + + for (const edge of unlockEdges) { + const candidateId = edge.sourceActionId; + await withKeyedLock(`mem:action:${candidateId}`, async () => { + const action = await kv.get(KV.actions, candidateId); + if (action && action.status === "blocked") { + const deps = allEdges.filter( + (e) => e.sourceActionId === action.id && e.type === "requires", + ); + const allDone = deps.every((d) => { + const target = actionMap.get(d.targetActionId); + return target && target.status === "done"; + }); + if (allDone) { + action.status = "pending"; + action.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, action.id, action); + } + } + }); + } +} diff --git a/src/functions/audit.ts b/src/functions/audit.ts new file mode 100644 index 0000000..1e75b4b --- /dev/null +++ b/src/functions/audit.ts @@ -0,0 +1,113 @@ +import type { AuditEntry } from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { logger } from "../logger.js"; + +// Audit coverage policy (issue #125). +// +// Every structural deletion of a memory, observation, session, or +// semantic row MUST call recordAudit. Two shapes are allowed, keyed to +// whether the caller is scoped or bulk: +// +// Scoped deletions — a user-visible, per-call action removing a +// bounded set of items. Emit ONE audit row per call with targetIds +// populated. Examples: mem::governance-delete, mem::forget. +// +// Bulk deletions — automatic sweeps (retention, TTL eviction, +// auto-forget) that can remove hundreds of rows per invocation. +// Emit ONE batched audit row per invocation with targetIds listing +// every removed id and details.evicted holding the count. Per-item +// audit rows would flood the audit log during routine sweeps. +// +// Either shape is required; silent deletes are not acceptable. +// +// operation field: +// - "delete" — permanent removal (governance, retention sweep, evict). +// - "forget" — forget/removal flows. Scoped when emitted by +// mem::forget (user-initiated); bulk-batched when +// emitted by mem::auto-forget (automatic sweep). +// - everything else — see AuditEntry["operation"] union in src/types.ts. +// +// When adding a new deletion path, add an explicit recordAudit call +// BEFORE kv.delete(...) and match one of the two shapes above. + +export async function recordAudit( + kv: StateKV, + operation: AuditEntry["operation"], + functionId: string, + targetIds: string[], + details: Record = {}, + qualityScore?: number, + userId?: string, +): Promise { + const entry: AuditEntry = { + id: generateId("aud"), + timestamp: new Date().toISOString(), + operation, + userId, + functionId, + targetIds, + details, + qualityScore, + }; + await kv.set(KV.audit, entry.id, entry); + return entry; +} + +export async function safeAudit( + kv: StateKV, + operation: AuditEntry["operation"], + functionId: string, + targetIds: string[], + details: Record = {}, + qualityScore?: number, + userId?: string, +): Promise { + try { + await recordAudit(kv, operation, functionId, targetIds, details, qualityScore, userId); + } catch (err) { + try { + logger.warn("audit write failed", { + functionId, + operation, + targetIds, + error: err instanceof Error ? err.message : String(err), + }); + } catch {} + } +} + +export async function queryAudit( + kv: StateKV, + filter?: { + operation?: AuditEntry["operation"]; + dateFrom?: string; + dateTo?: string; + limit?: number; + }, +): Promise { + const all = await kv.list(KV.audit); + let entries = [...all].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + + if (filter?.operation) { + entries = entries.filter((e) => e.operation === filter.operation); + } + if (filter?.dateFrom) { + const from = new Date(filter.dateFrom).getTime(); + if (Number.isNaN(from)) { + throw new Error(`Invalid dateFrom: ${filter.dateFrom}`); + } + entries = entries.filter((e) => new Date(e.timestamp).getTime() >= from); + } + if (filter?.dateTo) { + const to = new Date(filter.dateTo).getTime(); + if (Number.isNaN(to)) { + throw new Error(`Invalid dateTo: ${filter.dateTo}`); + } + entries = entries.filter((e) => new Date(e.timestamp).getTime() <= to); + } + + return entries.slice(0, filter?.limit || 100); +} diff --git a/src/functions/auto-forget.ts b/src/functions/auto-forget.ts new file mode 100644 index 0000000..74c571b --- /dev/null +++ b/src/functions/auto-forget.ts @@ -0,0 +1,206 @@ +import type { ISdk } from "iii-sdk"; +import type { Memory, CompressedObservation, Session } from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { deleteAccessLog } from "./access-tracker.js"; +import { getSearchIndex, vectorIndexRemove, flushIndexSave } from "./search.js"; +import { logger } from "../logger.js"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const CONTRADICTION_THRESHOLD = 0.9; + +interface AutoForgetResult { + ttlExpired: string[]; + contradictions: Array<{ + memoryA: string; + memoryB: string; + similarity: number; + }>; + lowValueObs: string[]; + dryRun: boolean; +} + +export function registerAutoForgetFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::auto-forget", + async (data: { dryRun?: boolean }): Promise => { + const dryRun = data?.dryRun ?? false; + const now = Date.now(); + const { decrementImageRef } = await import("./image-refs.js"); + + const result: AutoForgetResult = { + ttlExpired: [], + contradictions: [], + lowValueObs: [], + dryRun, + }; + + const memories = await kv.list(KV.memories); + const deletedIds = new Set(); + for (const mem of memories) { + if (mem.forgetAfter) { + const expiry = new Date(mem.forgetAfter).getTime(); + if (now > expiry) { + result.ttlExpired.push(mem.id); + deletedIds.add(mem.id); + if (!dryRun) { + if (mem.imageRef) { + await decrementImageRef(kv, sdk, mem.imageRef); + } + await kv.delete(KV.memories, mem.id); + await recordAudit(kv, "delete", "mem::auto-forget", [mem.id], { + resource: "memory", + reason: "auto-forget TTL", + timestamp: mem.forgetAfter, + }); + await deleteAccessLog(kv, mem.id); + getSearchIndex().remove(mem.id); + vectorIndexRemove(mem.id); + } + } + } + } + + const latestMemories = memories + .filter((m) => m.isLatest !== false && !deletedIds.has(m.id)) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) + .slice(0, 1000); + + const tokenCache = new Map>(); + for (const mem of latestMemories) { + tokenCache.set( + mem.id, + new Set( + mem.content + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2), + ), + ); + } + + const memById = new Map(latestMemories.map((m) => [m.id, m])); + const conceptIndex = new Map(); + for (const mem of latestMemories) { + const concepts = mem.concepts || []; + for (const c of concepts) { + const key = c.toLowerCase(); + if (!conceptIndex.has(key)) conceptIndex.set(key, []); + conceptIndex.get(key)!.push(mem.id); + } + } + + const compared = new Set(); + for (const [, memIds] of conceptIndex) { + for (let i = 0; i < memIds.length; i++) { + for (let j = i + 1; j < memIds.length; j++) { + const key = + memIds[i] < memIds[j] + ? `${memIds[i]}|${memIds[j]}` + : `${memIds[j]}|${memIds[i]}`; + if (compared.has(key)) continue; + compared.add(key); + + const setA = tokenCache.get(memIds[i])!; + const setB = tokenCache.get(memIds[j])!; + let intersection = 0; + if (setA.size === 0 && setB.size === 0) continue; + if (setA.size === 0 || setB.size === 0) continue; + for (const word of setA) { + if (setB.has(word)) intersection++; + } + const sim = + intersection / (setA.size + setB.size - intersection); + + if (sim > CONTRADICTION_THRESHOLD) { + const memA = memById.get(memIds[i])!; + const memB = memById.get(memIds[j])!; + result.contradictions.push({ + memoryA: memA.id, + memoryB: memB.id, + similarity: sim, + }); + + if (!dryRun) { + const older = + new Date(memA.createdAt).getTime() < + new Date(memB.createdAt).getTime() + ? memA + : memB; + older.isLatest = false; + await kv.set(KV.memories, older.id, older); + await recordAudit(kv, "forget", "mem::auto-forget", [older.id], { + resource: "memory", + reason: "auto-forget contradiction", + olderId: older.id, + similarity: sim, + }); + } + } + } + } + } + + const sessions = await kv.list(KV.sessions); + const obsPerSession: CompressedObservation[][] = []; + for (let batch = 0; batch < sessions.length; batch += 10) { + const chunk = sessions.slice(batch, batch + 10); + const results = await Promise.all( + chunk.map((s) => + kv + .list(KV.observations(s.id)) + .catch(() => [] as CompressedObservation[]), + ), + ); + obsPerSession.push(...results); + } + for (let i = 0; i < sessions.length; i++) { + for (const obs of obsPerSession[i]) { + if (!obs.timestamp) continue; + const age = now - new Date(obs.timestamp).getTime(); + if (age > 180 * MS_PER_DAY && (obs.importance ?? 5) <= 2) { + result.lowValueObs.push(obs.id); + if (!dryRun) { + let deletedOk = false; + try { + await kv.delete(KV.observations(sessions[i].id), obs.id); + deletedOk = true; + } catch { + deletedOk = false; + } + if (deletedOk) { + if (obs.imageData) await decrementImageRef(kv, sdk, obs.imageData); + if (obs.imageRef && obs.imageRef !== obs.imageData) { + await decrementImageRef(kv, sdk, obs.imageRef); + } + await recordAudit(kv, "delete", "mem::auto-forget", [obs.id], { + resource: "observation", + reason: "auto-forget low-value observation", + sessionId: sessions[i].id, + timestamp: obs.timestamp, + }); + getSearchIndex().remove(obs.id); + vectorIndexRemove(obs.id); + } + } + } + } + } + + if (!dryRun && (result.ttlExpired.length > 0 || result.lowValueObs.length > 0)) { + await flushIndexSave(); + } + + logger.info("Auto-forget complete", { + ttlExpired: result.ttlExpired.length, + contradictions: result.contradictions.length, + lowValueObs: result.lowValueObs.length, + dryRun, + }); + return result; + }, + ); +} diff --git a/src/functions/branch-aware.ts b/src/functions/branch-aware.ts new file mode 100644 index 0000000..d0240f4 --- /dev/null +++ b/src/functions/branch-aware.ts @@ -0,0 +1,166 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import type { Session } from "../types.js"; +import { execFile } from "node:child_process"; +import { resolve } from "node:path"; + +function execAsync( + cmd: string, + args: string[], + cwd: string, +): Promise { + return new Promise((resolve, reject) => { + execFile(cmd, args, { cwd, timeout: 5000 }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout.trim()); + }); + }); +} + +export function registerBranchAwareFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::detect-worktree", + async (data: { cwd: string }) => { + if (!data.cwd) { + return { success: false, error: "cwd is required" }; + } + + try { + const gitDir = await execAsync( + "git", + ["rev-parse", "--git-dir"], + data.cwd, + ); + const commonDir = await execAsync( + "git", + ["rev-parse", "--git-common-dir"], + data.cwd, + ); + const branch = await execAsync( + "git", + ["rev-parse", "--abbrev-ref", "HEAD"], + data.cwd, + ).catch(() => "detached"); + + const topLevel = await execAsync( + "git", + ["rev-parse", "--show-toplevel"], + data.cwd, + ); + + const isWorktree = resolve(data.cwd, gitDir) !== resolve(data.cwd, commonDir); + const mainRepoRoot = isWorktree + ? resolve(data.cwd, commonDir, "..") + : topLevel; + + return { + success: true, + isWorktree, + branch, + topLevel, + mainRepoRoot, + gitDir: resolve(data.cwd, gitDir), + commonDir: resolve(data.cwd, commonDir), + }; + } catch { + return { + success: true, + isWorktree: false, + branch: null, + topLevel: data.cwd, + mainRepoRoot: data.cwd, + gitDir: null, + commonDir: null, + }; + } + }, + ); + + sdk.registerFunction("mem::list-worktrees", + async (data: { cwd: string }) => { + if (!data.cwd) { + return { success: false, error: "cwd is required" }; + } + + try { + const output = await execAsync( + "git", + ["worktree", "list", "--porcelain"], + data.cwd, + ); + + const worktrees: Array<{ + path: string; + head: string; + branch: string; + bare: boolean; + }> = []; + + const blocks = output.split("\n\n").filter(Boolean); + for (const block of blocks) { + const lines = block.split("\n"); + const wt: { path: string; head: string; branch: string; bare: boolean } = { + path: "", + head: "", + branch: "", + bare: false, + }; + for (const line of lines) { + if (line.startsWith("worktree ")) wt.path = line.slice(9); + else if (line.startsWith("HEAD ")) wt.head = line.slice(5); + else if (line.startsWith("branch ")) + wt.branch = line.slice(7).replace("refs/heads/", ""); + else if (line === "bare") wt.bare = true; + } + if (wt.path) worktrees.push(wt); + } + + return { success: true, worktrees }; + } catch { + return { success: true, worktrees: [] }; + } + }, + ); + + sdk.registerFunction("mem::branch-sessions", + async (data: { cwd: string; branch?: string }) => { + if (!data.cwd) { + return { success: false, error: "cwd is required" }; + } + + const worktreeInfo = await sdk.trigger< + { cwd: string }, + { + success: boolean; + isWorktree: boolean; + mainRepoRoot: string; + branch: string | null; + } + >({ function_id: "mem::detect-worktree", payload: { cwd: data.cwd } }); + + const projectRoot = worktreeInfo.mainRepoRoot || data.cwd; + const branch = data.branch || worktreeInfo.branch; + + const sessions = await kv.list(KV.sessions); + + const matching = sessions.filter((s) => { + if (s.project === projectRoot || s.cwd === projectRoot) return true; + if (s.cwd.startsWith(projectRoot + "/")) return true; + return false; + }); + + matching.sort( + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ); + + return { + success: true, + sessions: matching, + projectRoot, + branch, + isWorktree: worktreeInfo.isWorktree, + }; + }, + ); +} diff --git a/src/functions/cascade.ts b/src/functions/cascade.ts new file mode 100644 index 0000000..fb979e6 --- /dev/null +++ b/src/functions/cascade.ts @@ -0,0 +1,90 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import type { Memory, GraphNode, GraphEdge } from "../types.js"; +import { recordAudit } from "./audit.js"; + +export function registerCascadeFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::cascade-update", + async (data: { supersededMemoryId: string }) => { + if (!data.supersededMemoryId || typeof data.supersededMemoryId !== "string") { + return { success: false, error: "supersededMemoryId is required" }; + } + + const superseded = await kv.get(KV.memories, data.supersededMemoryId); + if (!superseded) { + return { success: false, error: "superseded memory not found" }; + } + + let flaggedNodes = 0; + let flaggedEdges = 0; + let flaggedMemories = 0; + + const obsIds = new Set(superseded.sourceObservationIds || []); + + if (obsIds.size > 0) { + const now = new Date().toISOString(); + const nodes = await kv.list(KV.graphNodes); + for (const node of nodes) { + if (node.stale) continue; + const overlap = (node.sourceObservationIds ?? []).some((id) => obsIds.has(id)); + if (overlap) { + node.stale = true; + node.updatedAt = now; + await kv.set(KV.graphNodes, node.id, node); + await recordAudit(kv, "consolidate", "mem::cascade-update", [node.id], { + resourceType: "GraphNode", + change: "marked stale from superseded memory", + supersededMemoryId: data.supersededMemoryId, + }); + flaggedNodes++; + } + } + + const edges = await kv.list(KV.graphEdges); + for (const edge of edges) { + if (edge.stale) continue; + const overlap = (edge.sourceObservationIds ?? []).some((id) => obsIds.has(id)); + if (overlap) { + edge.stale = true; + await kv.set(KV.graphEdges, edge.id, edge); + await recordAudit(kv, "consolidate", "mem::cascade-update", [edge.id], { + resourceType: "GraphEdge", + change: "marked stale from superseded memory", + supersededMemoryId: data.supersededMemoryId, + }); + flaggedEdges++; + } + } + } + + const supersededConcepts = new Set( + (superseded.concepts ?? []).map((c) => c.toLowerCase()), + ); + if (supersededConcepts.size >= 2) { + const allMemories = await kv.list(KV.memories); + for (const mem of allMemories) { + if (mem.id === data.supersededMemoryId) continue; + if (!mem.isLatest) continue; + + const sharedCount = (mem.concepts ?? []).filter((c) => + supersededConcepts.has(c.toLowerCase()), + ).length; + if (sharedCount >= 2) { + flaggedMemories++; + } + } + } + + return { + success: true, + flagged: { + nodes: flaggedNodes, + edges: flaggedEdges, + siblingMemories: flaggedMemories, + }, + total: flaggedNodes + flaggedEdges + flaggedMemories, + }; + }, + ); +} diff --git a/src/functions/checkpoints.ts b/src/functions/checkpoints.ts new file mode 100644 index 0000000..efb3957 --- /dev/null +++ b/src/functions/checkpoints.ts @@ -0,0 +1,236 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import type { Action, ActionEdge, Checkpoint } from "../types.js"; +import { recordAudit } from "./audit.js"; + +export function registerCheckpointsFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::checkpoint-create", + async (data: { + name: string; + description?: string; + type?: Checkpoint["type"]; + linkedActionIds?: string[]; + expiresInMs?: number; + }) => { + if (!data.name) { + return { success: false, error: "name is required" }; + } + + const validTypes: Checkpoint["type"][] = ["ci", "approval", "deploy", "external", "timer"]; + if (data.type && !validTypes.includes(data.type)) { + return { success: false, error: `invalid checkpoint type: ${data.type}. Must be one of: ${validTypes.join(", ")}` }; + } + + const now = new Date(); + const checkpoint: Checkpoint = { + id: generateId("ckpt"), + name: data.name.trim(), + description: (data.description || "").trim(), + status: "pending", + type: data.type || "external", + createdAt: now.toISOString(), + linkedActionIds: data.linkedActionIds || [], + expiresAt: data.expiresInMs + ? new Date(now.getTime() + data.expiresInMs).toISOString() + : undefined, + }; + + if (data.linkedActionIds && data.linkedActionIds.length > 0) { + for (const actionId of data.linkedActionIds) { + const action = await kv.get(KV.actions, actionId); + if (!action) { + return { success: false, error: `linked action not found: ${actionId}` }; + } + } + } + + await kv.set(KV.checkpoints, checkpoint.id, checkpoint); + await recordAudit(kv, "checkpoint_resolve", "mem::checkpoint-create", [checkpoint.id], { + action: "create", + type: checkpoint.type, + name: checkpoint.name, + }); + + if (data.linkedActionIds && data.linkedActionIds.length > 0) { + for (const actionId of data.linkedActionIds) { + const edge: ActionEdge = { + id: generateId("ae"), + type: "gated_by", + sourceActionId: actionId, + targetActionId: checkpoint.id, + createdAt: now.toISOString(), + }; + await kv.set(KV.actionEdges, edge.id, edge); + + const action = await kv.get(KV.actions, actionId); + if (action && action.status === "pending") { + const previousStatus = action.status; + action.status = "blocked"; + action.updatedAt = now.toISOString(); + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "action_update", "mem::checkpoint-create", [action.id], { + action: "status-change", + previousStatus, + newStatus: action.status, + checkpointId: checkpoint.id, + }); + } + } + } + + return { success: true, checkpoint }; + }, + ); + + sdk.registerFunction("mem::checkpoint-resolve", + async (data: { + checkpointId: string; + status: "passed" | "failed"; + resolvedBy?: string; + result?: unknown; + }) => { + if (!data.checkpointId || !data.status) { + return { + success: false, + error: "checkpointId and status are required", + }; + } + + return withKeyedLock( + `mem:checkpoint:${data.checkpointId}`, + async () => { + const checkpoint = await kv.get( + KV.checkpoints, + data.checkpointId, + ); + if (!checkpoint) { + return { success: false, error: "checkpoint not found" }; + } + if (checkpoint.status !== "pending") { + return { + success: false, + error: `checkpoint already ${checkpoint.status}`, + }; + } + + checkpoint.status = data.status; + checkpoint.resolvedAt = new Date().toISOString(); + checkpoint.resolvedBy = data.resolvedBy; + checkpoint.result = data.result; + + await kv.set(KV.checkpoints, checkpoint.id, checkpoint); + await recordAudit(kv, "checkpoint_resolve", "mem::checkpoint-resolve", [checkpoint.id], { + action: "resolve", + resolvedBy: data.resolvedBy, + result: data.result, + newStatus: checkpoint.status, + }); + + let unblockedCount = 0; + if (data.status === "passed" && checkpoint.linkedActionIds.length > 0) { + const allEdges = await kv.list(KV.actionEdges); + const allCheckpoints = await kv.list(KV.checkpoints); + const allActions = await kv.list(KV.actions); + const cpMap = new Map(allCheckpoints.map((c) => [c.id, c])); + const actionMap = new Map(allActions.map((a) => [a.id, a])); + + for (const actionId of checkpoint.linkedActionIds) { + await withKeyedLock(`mem:action:${actionId}`, async () => { + const action = await kv.get(KV.actions, actionId); + if (action && action.status === "blocked") { + const gates = allEdges.filter( + (e) => e.sourceActionId === actionId && e.type === "gated_by", + ); + const allGatesPassed = gates.every((g) => { + const cp = cpMap.get(g.targetActionId); + return cp && cp.status === "passed"; + }); + const requires = allEdges.filter( + (e) => e.sourceActionId === actionId && e.type === "requires", + ); + const allRequiresMet = requires.every((r) => { + const dep = actionMap.get(r.targetActionId); + return dep && dep.status === "done"; + }); + if (allGatesPassed && allRequiresMet) { + const previousStatus = action.status; + action.status = "pending"; + action.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "action_update", "mem::checkpoint-resolve", [action.id], { + action: "unblock", + checkpointId: checkpoint.id, + previousStatus, + newStatus: action.status, + }); + unblockedCount++; + } + } + }); + } + } + + return { success: true, checkpoint, unblockedCount }; + }, + ); + }, + ); + + sdk.registerFunction("mem::checkpoint-list", + async (data: { status?: string; type?: string }) => { + let checkpoints = await kv.list(KV.checkpoints); + + if (data.status) { + checkpoints = checkpoints.filter((c) => c.status === data.status); + } + if (data.type) { + checkpoints = checkpoints.filter((c) => c.type === data.type); + } + + checkpoints.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return { success: true, checkpoints }; + }, + ); + + sdk.registerFunction("mem::checkpoint-expire", + async () => { + const checkpoints = await kv.list(KV.checkpoints); + const now = Date.now(); + let expired = 0; + + for (const cp of checkpoints) { + if ( + cp.status === "pending" && + cp.expiresAt && + new Date(cp.expiresAt).getTime() <= now + ) { + const didExpire = await withKeyedLock( + `mem:checkpoint:${cp.id}`, + async () => { + const fresh = await kv.get(KV.checkpoints, cp.id); + if (!fresh || fresh.status !== "pending") return false; + fresh.status = "expired"; + fresh.resolvedAt = new Date().toISOString(); + await kv.set(KV.checkpoints, fresh.id, fresh); + await recordAudit(kv, "checkpoint_resolve", "mem::checkpoint-expire", [fresh.id], { + action: "expire", + previousStatus: "pending", + newStatus: "expired", + }); + return true; + }, + ); + if (didExpire) expired++; + } + } + + return { success: true, expired }; + }, + ); +} diff --git a/src/functions/claude-bridge.ts b/src/functions/claude-bridge.ts new file mode 100644 index 0000000..3aba76f --- /dev/null +++ b/src/functions/claude-bridge.ts @@ -0,0 +1,162 @@ +import type { ISdk } from "iii-sdk"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { dirname } from "node:path"; +import type { Memory, ClaudeBridgeConfig } from "../types.js"; +import { KV } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +function parseMemoryMd(content: string): { + sections: Map; + raw: string; +} { + const sections = new Map(); + let currentSection = ""; + let currentContent: string[] = []; + + for (const line of content.split("\n")) { + if (line.startsWith("## ")) { + if (currentSection) { + sections.set(currentSection, currentContent.join("\n").trim()); + } + currentSection = line.slice(3).trim(); + currentContent = []; + } else { + currentContent.push(line); + } + } + if (currentSection) { + sections.set(currentSection, currentContent.join("\n").trim()); + } + + return { sections, raw: content }; +} + +function serializeToMemoryMd( + memories: Memory[], + projectSummary: string, + lineBudget: number, +): string { + const lines: string[] = []; + lines.push("# Agent Memory (auto-synced by agentmemory)"); + lines.push(""); + + if (projectSummary) { + lines.push("## Project Summary"); + lines.push(projectSummary); + lines.push(""); + } + + lines.push("## Key Memories"); + lines.push(""); + + const sorted = [...memories] + .filter((m) => m.isLatest) + .sort((a, b) => b.strength - a.strength); + + for (const mem of sorted) { + if (lines.length >= lineBudget - 2) break; + lines.push(`### ${mem.title}`); + const contentLines = mem.content.split("\n"); + for (const cl of contentLines) { + if (lines.length >= lineBudget - 1) break; + lines.push(cl); + } + lines.push(""); + } + + return lines.slice(0, lineBudget).join("\n"); +} + +export function registerClaudeBridgeFunction( + sdk: ISdk, + kv: StateKV, + config: ClaudeBridgeConfig, +): void { + sdk.registerFunction("mem::claude-bridge-read", + async () => { + if (!config.enabled || !config.memoryFilePath) { + return { success: false, error: "Claude bridge not configured" }; + } + + try { + if (!existsSync(config.memoryFilePath)) { + return { success: true, content: "", parsed: false }; + } + const content = readFileSync(config.memoryFilePath, "utf-8"); + const { sections } = parseMemoryMd(content); + + await kv.set(KV.claudeBridge, "last-read", { + timestamp: new Date().toISOString(), + sections: Object.fromEntries(sections), + lineCount: content.split("\n").length, + }); + await recordAudit(kv, "export", "mem::claude-bridge-read", ["last-read"], { + timestamp: new Date().toISOString(), + sections: Object.keys(Object.fromEntries(sections)), + lineCount: content.split("\n").length, + }); + + logger.info("Claude bridge: read MEMORY.md", { + path: config.memoryFilePath, + lines: content.split("\n").length, + }); + return { success: true, content, sections: Object.fromEntries(sections) }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Claude bridge read failed", { error: msg }); + return { success: false, error: msg }; + } + }, + ); + + sdk.registerFunction("mem::claude-bridge-sync", + async () => { + if (!config.enabled || !config.memoryFilePath) { + return { success: false, error: "Claude bridge not configured" }; + } + + try { + const memories = await kv.list(KV.memories); + const latestMemories = memories.filter((m) => m.isLatest); + + let projectSummary = ""; + if (config.projectPath) { + const profile = await kv + .get<{ summary?: string }>(KV.profiles, config.projectPath) + .catch(() => null); + projectSummary = profile?.summary || ""; + } + + const md = serializeToMemoryMd( + latestMemories, + projectSummary, + config.lineBudget, + ); + + const dir = dirname(config.memoryFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(config.memoryFilePath, md, "utf-8"); + + await recordAudit(kv, "export", "mem::claude-bridge-sync", [], { + path: config.memoryFilePath, + memoryCount: latestMemories.length, + lines: md.split("\n").length, + }); + + logger.info("Claude bridge: synced to MEMORY.md", { + path: config.memoryFilePath, + memories: latestMemories.length, + }); + return { success: true, path: config.memoryFilePath, lines: md.split("\n").length }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Claude bridge sync failed", { error: msg }); + return { success: false, error: msg }; + } + }, + ); +} diff --git a/src/functions/compress-file.ts b/src/functions/compress-file.ts new file mode 100644 index 0000000..0a54452 --- /dev/null +++ b/src/functions/compress-file.ts @@ -0,0 +1,188 @@ +import { constants } from "node:fs"; +import { lstat, open, readFile, writeFile } from "node:fs/promises"; +import { basename, dirname, extname, join, resolve } from "node:path"; +import type { ISdk } from "iii-sdk"; +import type { MemoryProvider } from "../types.js"; +import type { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; + +const SENSITIVE_PATH_TERMS = [ + "secret", + "credential", + "private_key", + ".env", + "id_rsa", + "token", +]; + +const COMPRESS_FILE_SYSTEM_PROMPT = `You compress markdown while preserving structure. +Rules: +- Keep all headings exactly as-is. +- Keep all URLs exactly as-is. +- Keep all fenced code blocks exactly as-is. +- Do not remove sections; shorten prose under each section. +- Output only markdown, no wrappers or explanations.`; + +function stripMarkdownFence(text: string): string { + const trimmed = text.trim(); + const match = trimmed.match(/^```(?:markdown|md)?\s*([\s\S]*?)\s*```$/i); + return match ? match[1].trim() : trimmed; +} + +function extractUrls(text: string): string[] { + return Array.from(new Set(text.match(/https?:\/\/[^\s)]+/g) || [])); +} + +function extractHeadings(text: string): string[] { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^#{1,6}\s+/.test(line)); +} + +function extractCodeBlocks(text: string): string[] { + return text.match(/```[\s\S]*?```/g) || []; +} + +function validateCompression(original: string, compressed: string): string[] { + const errors: string[] = []; + + const originalHeadings = extractHeadings(original); + const compressedHeadings = extractHeadings(compressed); + for (const heading of originalHeadings) { + if (!compressedHeadings.includes(heading)) { + errors.push(`missing heading: ${heading}`); + } + } + + const originalUrls = extractUrls(original).sort(); + const compressedUrls = extractUrls(compressed).sort(); + if (originalUrls.length !== compressedUrls.length) { + errors.push("url count changed"); + } else { + for (let i = 0; i < originalUrls.length; i++) { + if (originalUrls[i] !== compressedUrls[i]) { + errors.push("url set changed"); + break; + } + } + } + + const originalBlocks = extractCodeBlocks(original); + const compressedBlocks = extractCodeBlocks(compressed); + if (originalBlocks.length !== compressedBlocks.length) { + errors.push("code block count changed"); + } else { + for (let i = 0; i < originalBlocks.length; i++) { + if (originalBlocks[i] !== compressedBlocks[i]) { + errors.push("code block content changed"); + break; + } + } + } + + return errors; +} + +function resolveBackupPath(filePath: string): string { + const base = basename(filePath, extname(filePath)); + const name = base.endsWith(".original") + ? `${base}.backup` + : `${base}.original`; + return join(dirname(filePath), `${name}.md`); +} + +export function registerCompressFileFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction( + "mem::compress-file", + async (data: { filePath: string }) => { + if (!data?.filePath || typeof data.filePath !== "string") { + return { success: false, error: "filePath is required" }; + } + + const absolutePath = resolve(data.filePath); + const lowerPath = absolutePath.toLowerCase(); + if (extname(absolutePath).toLowerCase() !== ".md") { + return { success: false, error: "filePath must point to a .md file" }; + } + if (SENSITIVE_PATH_TERMS.some((term) => lowerPath.includes(term))) { + return { success: false, error: "refusing to process sensitive-looking path" }; + } + + try { + const stat = await lstat(absolutePath); + if (stat.isSymbolicLink()) { + return { success: false, error: "symlinks are not supported" }; + } + } catch { + return { success: false, error: "file not found" }; + } + + let original: string; + try { + original = await readFile(absolutePath, "utf-8"); + } catch { + return { success: false, error: "failed to read file" }; + } + + if (!original.trim()) { + return { success: true, skipped: true, reason: "file is empty" }; + } + + const response = await provider.summarize( + COMPRESS_FILE_SYSTEM_PROMPT, + `Compress this markdown file while preserving structure and code blocks:\n\n${original}`, + ); + const compressed = stripMarkdownFence(response); + const validationErrors = validateCompression(original, compressed); + if (validationErrors.length > 0) { + return { + success: false, + error: "compression validation failed", + details: validationErrors, + }; + } + + const backupPath = resolveBackupPath(absolutePath); + await writeFile(backupPath, original, "utf-8"); + + let fd: Awaited> | null = null; + try { + fd = await open( + absolutePath, + constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, + ); + await fd.writeFile(compressed, "utf-8"); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ELOOP" || code === "EINVAL") { + return { success: false, error: "symlinks are not supported" }; + } + return { success: false, error: "failed to write compressed file" }; + } finally { + await fd?.close().catch(() => {}); + } + + try { + await recordAudit(kv, "compress", "mem::compress-file", [], { + filePath: absolutePath, + backupPath, + originalChars: original.length, + compressedChars: compressed.length, + }); + } catch {} + + return { + success: true, + filePath: absolutePath, + backupPath, + originalChars: original.length, + compressedChars: compressed.length, + }; + }, + ); +} diff --git a/src/functions/compress-synthetic.ts b/src/functions/compress-synthetic.ts new file mode 100644 index 0000000..28d17e9 --- /dev/null +++ b/src/functions/compress-synthetic.ts @@ -0,0 +1,106 @@ +import type { + RawObservation, + CompressedObservation, + ObservationType, +} from "../types.js"; + +// Zero-LLM compression path. Converts a RawObservation into a +// CompressedObservation using only heuristics — no Claude call, no token +// spend. This is the default as of 0.8.8 (#138); users who want richer +// LLM-generated summaries set AGENTMEMORY_AUTO_COMPRESS=true. + +function inferType( + toolName: string | undefined, + hookType: string, +): ObservationType { + if (hookType === "post_tool_failure") return "error"; + if (hookType === "prompt_submit") return "conversation"; + if (hookType === "subagent_stop" || hookType === "task_completed") + return "subagent"; + if (hookType === "notification") return "notification"; + + if (!toolName) return "other"; + // Normalize camelCase and kebab-case into word chunks so we can match + // substrings like "WebFetch" -> "web" / "fetch". + const n = toolName + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[-\s]+/g, "_") + .toLowerCase(); + const hasWord = (word: string) => + new RegExp(`(^|_)${word}(_|$)`).test(n) || + n === word || + n.endsWith(word) || + n.startsWith(word); + if (["fetch", "http", "web"].some(hasWord)) return "web_fetch"; + if (["grep", "search", "glob", "find"].some(hasWord)) return "search"; + if (["bash", "shell", "exec", "run"].some(hasWord)) return "command_run"; + if (["edit", "update", "patch", "replace"].some(hasWord)) return "file_edit"; + if (["write", "create"].some(hasWord)) return "file_write"; + if (["read", "view"].some(hasWord)) return "file_read"; + if (["task", "agent"].some(hasWord)) return "subagent"; + return "other"; +} + +function extractFiles(input: unknown): string[] { + if (!input || typeof input !== "object") return []; + const o = input as Record; + const out = new Set(); + for (const key of [ + "file_path", + "filepath", + "path", + "filePath", + "file", + "pattern", + ]) { + const v = o[key]; + if (typeof v === "string" && v.length > 0 && v.length < 512) out.add(v); + } + return [...out]; +} + +function stringifyForNarrative(v: unknown): string { + if (v == null) return ""; + if (typeof v === "string") return v; + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n - 1) + "\u2026" : s; +} + +export function buildSyntheticCompression( + raw: RawObservation, +): CompressedObservation { + const toolName = raw.toolName ?? raw.hookType; + const inputStr = stringifyForNarrative(raw.toolInput); + const outputStr = stringifyForNarrative(raw.toolOutput); + const promptStr = raw.userPrompt ?? ""; + + const narrativeParts = [promptStr, inputStr, outputStr].filter( + (s) => s.length > 0, + ); + + const result: CompressedObservation = { + id: raw.id, + sessionId: raw.sessionId, + timestamp: raw.timestamp, + type: inferType(toolName, raw.hookType), + title: truncate(toolName || "observation", 80), + subtitle: inputStr ? truncate(inputStr, 120) : undefined, + facts: [], + narrative: truncate(narrativeParts.join(" | "), 400), + concepts: [], + files: extractFiles(raw.toolInput), + importance: 5, + confidence: 0.3, + }; + if (raw.modality) result.modality = raw.modality; + if (raw.imageData) result.imageData = raw.imageData; + if (raw.agentId) result.agentId = raw.agentId; + return result; +} diff --git a/src/functions/compress.ts b/src/functions/compress.ts new file mode 100644 index 0000000..0569555 --- /dev/null +++ b/src/functions/compress.ts @@ -0,0 +1,267 @@ +import { TriggerAction, type ISdk } from "iii-sdk"; +import { readFileSync } from "node:fs"; +import { isManagedImagePath } from "../utils/image-store.js"; +import type { + RawObservation, + CompressedObservation, + ObservationType, + MemoryProvider, +} from "../types.js"; +import { KV, STREAM } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { + COMPRESSION_SYSTEM, + buildCompressionPrompt, +} from "../prompts/compression.js"; +import { VISION_DESCRIPTION_PROMPT } from "../prompts/vision.js"; +import { getXmlTag, getXmlChildren } from "../prompts/xml.js"; +import { getSearchIndex, vectorIndexAddGuarded } from "./search.js"; +import { CompressOutputSchema } from "../eval/schemas.js"; +import { validateOutput } from "../eval/validator.js"; +import { scoreCompression } from "../eval/quality.js"; +import { compressWithRetry } from "../eval/self-correct.js"; +import type { MetricsStore } from "../eval/metrics-store.js"; +import { logger } from "../logger.js"; + +const VALID_TYPES = new Set([ + "file_read", + "file_write", + "file_edit", + "command_run", + "search", + "web_fetch", + "conversation", + "error", + "decision", + "discovery", + "subagent", + "notification", + "task", + "image", + "other", +]); + +function parseCompressionXml( + xml: string, +): Omit | null { + const rawType = getXmlTag(xml, "type"); + const title = getXmlTag(xml, "title"); + if (!rawType || !title) return null; + const type = VALID_TYPES.has(rawType) ? rawType : "other"; + + return { + type: type as ObservationType, + title, + subtitle: getXmlTag(xml, "subtitle") || undefined, + facts: getXmlChildren(xml, "facts", "fact"), + narrative: getXmlTag(xml, "narrative"), + concepts: getXmlChildren(xml, "concepts", "concept"), + files: getXmlChildren(xml, "files", "file"), + importance: Math.max( + 1, + Math.min(10, parseInt(getXmlTag(xml, "importance") || "5", 10) || 5), + ), + }; +} + +export function registerCompressFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, + metricsStore?: MetricsStore, +): void { + sdk.registerFunction("mem::compress", + async (data: { + observationId: string; + sessionId: string; + raw: RawObservation; + }) => { + const startMs = Date.now(); + + let imageDescription: string | undefined; + const hasImage = data.raw.modality === "image" || data.raw.modality === "mixed"; + + if (hasImage && data.raw.imageData && provider.describeImage) { + try { + let base64Data = data.raw.imageData; + let mimeType = "image/png"; + + if (!data.raw.imageData.startsWith("/9j/") && !data.raw.imageData.startsWith("iVBOR")) { + if (!isManagedImagePath(data.raw.imageData)) { + throw new Error(`Refusing to read image outside managed store: ${data.raw.imageData}`); + } + const fileBuffer = readFileSync(data.raw.imageData); + base64Data = fileBuffer.toString("base64"); + if (data.raw.imageData.endsWith(".jpg") || data.raw.imageData.endsWith(".jpeg")) mimeType = "image/jpeg"; + else if (data.raw.imageData.endsWith(".webp")) mimeType = "image/webp"; + else if (data.raw.imageData.endsWith(".gif")) mimeType = "image/gif"; + } + + imageDescription = await provider.describeImage(base64Data, mimeType, VISION_DESCRIPTION_PROMPT); + logger.info("Image described by vision model", { obsId: data.observationId }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("Vision model call failed, falling back to text-only compression", { + obsId: data.observationId, + error: msg, + }); + } + } + + const prompt = buildCompressionPrompt({ + hookType: data.raw.hookType, + toolName: data.raw.toolName, + toolInput: data.raw.toolInput, + toolOutput: imageDescription + ? `[Image Description]: ${imageDescription}\n\n${data.raw.toolOutput ?? ""}` + : data.raw.toolOutput, + userPrompt: data.raw.userPrompt, + timestamp: data.raw.timestamp, + }); + + try { + const validator = (response: string) => { + const parsed = parseCompressionXml(response); + if (!parsed) return { valid: false, errors: ["xml_parse_failed"] }; + const result = validateOutput( + CompressOutputSchema, + parsed, + "mem::compress", + ); + return result.valid + ? { valid: true } + : { valid: false, errors: result.result.errors }; + }; + + const { response, retried } = await compressWithRetry( + provider, + COMPRESSION_SYSTEM, + prompt, + validator, + 1, + ); + + const parsed = parseCompressionXml(response); + if (!parsed) { + const latencyMs = Date.now() - startMs; + if (metricsStore) { + await metricsStore.record("mem::compress", latencyMs, false); + } + logger.warn("Failed to parse compression XML", { + obsId: data.observationId, + retried, + }); + return { success: false, error: "parse_failed" }; + } + + const qualityScore = scoreCompression(parsed); + + const compressed: CompressedObservation = { + id: data.observationId, + sessionId: data.sessionId, + timestamp: data.raw.timestamp, + ...parsed, + confidence: qualityScore / 100, + ...(hasImage ? { modality: data.raw.modality } : {}), + ...(imageDescription ? { imageDescription } : {}), + ...(data.raw.imageData ? { imageRef: data.raw.imageData } : {}), + ...(data.raw.agentId ? { agentId: data.raw.agentId } : {}), + }; + + await kv.set( + KV.observations(data.sessionId), + data.observationId, + compressed, + ); + + try { + getSearchIndex().add(compressed); + } catch (err) { + logger.warn("Failed to index compressed observation into BM25", { + obsId: compressed.id, + sessionId: compressed.sessionId, + title: compressed.title, + error: err instanceof Error ? err.message : String(err), + }); + } + + await vectorIndexAddGuarded( + compressed.id, + compressed.sessionId, + compressed.title + " " + (compressed.narrative || ""), + { kind: "observation", logId: compressed.id }, + ); + + const streamResults = await Promise.allSettled([ + sdk.trigger({ + function_id: "stream::set", + payload: { + stream_name: STREAM.name, + group_id: STREAM.group(data.sessionId), + item_id: data.observationId, + data: { type: "compressed", observation: compressed }, + }, + }), + sdk.trigger({ + function_id: "stream::send", + payload: { + stream_name: STREAM.name, + group_id: STREAM.viewerGroup, + id: `compressed-${data.observationId}`, + type: "compressed_observation", + data: { + type: "compressed", + observation: compressed, + sessionId: data.sessionId, + }, + }, + action: TriggerAction.Void(), + }), + ]); + for (const result of streamResults) { + if (result.status === "rejected") { + logger.warn("Non-fatal stream publish failure after compress", { + sessionId: data.sessionId, + observationId: data.observationId, + error: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + }); + } + } + + const latencyMs = Date.now() - startMs; + if (metricsStore) { + await metricsStore.record( + "mem::compress", + latencyMs, + true, + qualityScore, + ); + } + + logger.info("Observation compressed", { + obsId: data.observationId, + type: compressed.type, + importance: compressed.importance, + qualityScore, + retried, + }); + + return { success: true, compressed, qualityScore }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const latencyMs = Date.now() - startMs; + if (metricsStore) { + await metricsStore.record("mem::compress", latencyMs, false); + } + logger.error("Compression failed", { + obsId: data.observationId, + error: msg, + }); + return { success: false, error: "compression_failed" }; + } + }, + ); +} diff --git a/src/functions/consolidate.ts b/src/functions/consolidate.ts new file mode 100644 index 0000000..d394e17 --- /dev/null +++ b/src/functions/consolidate.ts @@ -0,0 +1,241 @@ +import type { ISdk } from "iii-sdk"; +import type { + CompressedObservation, + Memory, + Session, + MemoryProvider, +} from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; + +const CONSOLIDATION_SYSTEM = `You are a memory consolidation engine. Given a set of related observations from coding sessions, synthesize them into a single long-term memory. + +Output XML: + + pattern|preference|architecture|bug|workflow|fact + Concise memory title (max 80 chars) + 2-4 sentence description of the learned insight + + key term + + + relevant/file/path + + 1-10 how confident/important this memory is +`; + +import { getXmlTag, getXmlChildren } from "../prompts/xml.js"; +import { logger } from "../logger.js"; + +function parseMemoryXml( + xml: string, + sessionIds: string[], +): Omit | null { + const type = getXmlTag(xml, "type"); + const title = getXmlTag(xml, "title"); + const content = getXmlTag(xml, "content"); + if (!type || !title || !content) return null; + + const validTypes = new Set([ + "pattern", + "preference", + "architecture", + "bug", + "workflow", + "fact", + ]); + + return { + type: (validTypes.has(type) ? type : "fact") as Memory["type"], + title, + content, + concepts: getXmlChildren(xml, "concepts", "concept"), + files: getXmlChildren(xml, "files", "file"), + sessionIds, + strength: Math.max( + 1, + Math.min(10, parseInt(getXmlTag(xml, "strength") || "5", 10) || 5), + ), + version: 1, + isLatest: true, + }; +} + +export function registerConsolidateFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::consolidate", + async (data: { project?: string; minObservations?: number }) => { + const minObs = data.minObservations ?? 10; + + const sessions = await kv.list(KV.sessions); + const filtered = data.project + ? sessions.filter((s) => s.project === data.project) + : sessions; + + const allObs: Array = []; + const obsPerSession: CompressedObservation[][] = []; + for (let batch = 0; batch < filtered.length; batch += 10) { + const chunk = filtered.slice(batch, batch + 10); + const results = await Promise.all( + chunk.map((s) => + kv + .list(KV.observations(s.id)) + .catch(() => [] as CompressedObservation[]), + ), + ); + obsPerSession.push(...results); + } + for (let i = 0; i < filtered.length; i++) { + for (const obs of obsPerSession[i]) { + if (obs.title && obs.importance >= 5) { + allObs.push({ ...obs, sid: filtered[i].id }); + } + } + } + + if (allObs.length < minObs) { + return { consolidated: 0, reason: "insufficient_observations" }; + } + + const conceptGroups = new Map(); + for (const obs of allObs) { + for (const concept of obs.concepts) { + const key = concept.toLowerCase(); + if (!conceptGroups.has(key)) conceptGroups.set(key, []); + conceptGroups.get(key)!.push(obs); + } + } + + let consolidated = 0; + const existingMemories = await kv.list(KV.memories); + const existingTitles = new Set( + existingMemories.map((m) => m.title.toLowerCase()), + ); + + const MAX_LLM_CALLS = 10; + let llmCallCount = 0; + + const sortedGroups = [...conceptGroups.entries()] + .filter(([, g]) => g.length >= 3) + .sort((a, b) => b[1].length - a[1].length); + + for (const [concept, obsGroup] of sortedGroups) { + if (llmCallCount >= MAX_LLM_CALLS) break; + + const top = obsGroup + .sort((a, b) => b.importance - a.importance) + .slice(0, 8); + const sessionIds = [...new Set(top.map((o) => o.sid))]; + + const prompt = top + .map( + (o) => + `[${o.type}] ${o.title}\n${o.narrative}\nFiles: ${o.files.join(", ")}\nImportance: ${o.importance}`, + ) + .join("\n\n"); + + try { + const response = await Promise.race([ + provider.compress( + CONSOLIDATION_SYSTEM, + `Concept: "${concept}"\n\nObservations:\n${prompt}`, + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error("compress timeout")), 30_000), + ), + ]); + llmCallCount++; + const parsed = parseMemoryXml(response, sessionIds); + if (!parsed) continue; + + const now = new Date().toISOString(); + const obsIds = [...new Set(top.map((o) => o.id))]; + const scopedProject = + typeof data.project === "string" && data.project.trim().length > 0 + ? data.project.trim() + : undefined; + + // A scoped consolidation run must only evolve memories that belong + // to the same project. Without this guard, two projects that happen + // to consolidate observations into an identically-titled memory would + // cause one project's memory to silently evolve the other's — the + // exact class of cross-project corruption this fix is designed to + // prevent. An unscoped run (no data.project, background cron path) + // preserves the pre-existing behavior and may evolve any memory. + const existingMatch = existingMemories.find( + (m) => + m.title.toLowerCase() === parsed.title.toLowerCase() && + (!scopedProject || !m.project || m.project === scopedProject), + ); + + if (existingMatch) { + existingMatch.isLatest = false; + await kv.set(KV.memories, existingMatch.id, existingMatch); + await recordAudit(kv, "evolve", "mem::consolidate", [existingMatch.id], { + action: "mark_non_latest", + concept, + }); + + const evolved: Memory = { + id: generateId("mem"), + createdAt: now, + updatedAt: now, + ...parsed, + version: (existingMatch.version || 1) + 1, + parentId: existingMatch.id, + supersedes: [ + existingMatch.id, + ...(existingMatch.supersedes || []), + ], + sourceObservationIds: obsIds, + isLatest: true, + ...(scopedProject !== undefined && { project: scopedProject }), + }; + await kv.set(KV.memories, evolved.id, evolved); + await recordAudit(kv, "evolve", "mem::consolidate", [evolved.id], { + action: "evolve_memory", + oldId: existingMatch.id, + newId: evolved.id, + concept, + }); + existingTitles.add(evolved.title.toLowerCase()); + consolidated++; + } else { + const memory: Memory = { + id: generateId("mem"), + createdAt: now, + updatedAt: now, + ...parsed, + sourceObservationIds: obsIds, + version: 1, + isLatest: true, + ...(scopedProject !== undefined && { project: scopedProject }), + }; + await kv.set(KV.memories, memory.id, memory); + await recordAudit(kv, "remember", "mem::consolidate", [memory.id], { + action: "create_memory", + concept, + }); + existingTitles.add(memory.title.toLowerCase()); + consolidated++; + } + } catch (err) { + logger.warn("Consolidation failed for concept", { + concept, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + logger.info("Consolidation complete", { + consolidated, + totalObs: allObs.length, + }); + return { consolidated, totalObservations: allObs.length }; + }, + ); +} diff --git a/src/functions/consolidation-pipeline.ts b/src/functions/consolidation-pipeline.ts new file mode 100644 index 0000000..e51ab97 --- /dev/null +++ b/src/functions/consolidation-pipeline.ts @@ -0,0 +1,270 @@ +import type { ISdk } from "iii-sdk"; +import type { + SemanticMemory, + ProceduralMemory, + SessionSummary, + Memory, + MemoryProvider, +} from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { + SEMANTIC_MERGE_SYSTEM, + buildSemanticMergePrompt, + PROCEDURAL_EXTRACTION_SYSTEM, + buildProceduralExtractionPrompt, +} from "../prompts/consolidation.js"; +import { recordAudit } from "./audit.js"; +import { getConsolidationDecayDays, isConsolidationEnabled } from "../config.js"; +import { logger } from "../logger.js"; + +function applyDecay( + items: Array<{ + strength: number; + lastAccessedAt?: string; + updatedAt: string; + }>, + decayDays: number, +): void { + if (decayDays <= 0 || !Number.isFinite(decayDays)) return; + const now = Date.now(); + for (const item of items) { + const lastAccess = item.lastAccessedAt || item.updatedAt; + const daysSince = + (now - new Date(lastAccess).getTime()) / (1000 * 60 * 60 * 24); + if (daysSince > decayDays) { + const decayPeriods = Math.floor(daysSince / decayDays); + item.strength = Math.max( + 0.1, + item.strength * Math.pow(0.9, decayPeriods), + ); + } + } +} + +export function registerConsolidationPipelineFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::consolidate-pipeline", + async (data?: { tier?: string; force?: boolean; project?: string }) => { + if (!data?.force && !isConsolidationEnabled()) { + return { success: false, skipped: true, reason: "Consolidation disabled: set CONSOLIDATION_ENABLED=true or configure an LLM provider (ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY / MINIMAX_API_KEY / OPENAI_BASE_URL / AGENTMEMORY_PROVIDER=agent-sdk)" }; + } + const tier = data?.tier || "all"; + const decayDays = getConsolidationDecayDays(); + const results: Record = {}; + + if (tier === "all" || tier === "semantic") { + const summaries = await kv.list(KV.summaries); + const existingSemantic = await kv.list(KV.semantic); + + if (summaries.length >= 5) { + const recentSummaries = summaries + .sort( + (a, b) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime(), + ) + .slice(0, 20); + + const prompt = buildSemanticMergePrompt( + recentSummaries.map((s) => ({ + title: s.title, + narrative: s.narrative, + concepts: s.concepts, + })), + ); + + try { + const response = await provider.summarize( + SEMANTIC_MERGE_SYSTEM, + prompt, + ); + + const factRegex = /([^<]+)<\/fact>/g; + let match; + let newFacts = 0; + const now = new Date().toISOString(); + + while ((match = factRegex.exec(response)) !== null) { + const parsedConf = parseFloat(match[1]); + const confidence = Number.isNaN(parsedConf) ? 0.5 : parsedConf; + const fact = match[2].trim(); + + const existing = existingSemantic.find( + (s) => s.fact.toLowerCase() === fact.toLowerCase(), + ); + if (existing) { + existing.accessCount++; + existing.lastAccessedAt = now; + existing.updatedAt = now; + existing.confidence = Math.max(existing.confidence, confidence); + await kv.set(KV.semantic, existing.id, existing); + } else { + const sem: SemanticMemory = { + id: generateId("sem"), + fact, + confidence, + sourceSessionIds: recentSummaries.map((s) => s.sessionId), + sourceMemoryIds: [], + accessCount: 1, + lastAccessedAt: now, + strength: confidence, + createdAt: now, + updatedAt: now, + }; + await kv.set(KV.semantic, sem.id, sem); + newFacts++; + } + } + results.semantic = { newFacts, totalSummaries: summaries.length }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Semantic consolidation failed", { error: msg }); + results.semantic = { error: msg }; + } + } else { + results.semantic = { + skipped: true, + reason: "fewer than 5 summaries", + }; + } + } + + if (tier === "all" || tier === "reflect") { + try { + const reflectResult = await sdk.trigger({ function_id: "mem::reflect", payload: { + maxClusters: 10, + project: data?.project, + } }); + results.reflect = reflectResult; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("Reflect tier failed", { error: msg }); + results.reflect = { error: msg }; + } + } + + if (tier === "all" || tier === "procedural") { + const memories = await kv.list(KV.memories); + const patterns = memories + .filter((m) => m.isLatest && m.type === "pattern") + .map((m) => ({ + content: m.content, + frequency: m.sessionIds.length || 1, + })) + .filter((p) => p.frequency >= 2); + + if (patterns.length >= 2) { + const prompt = buildProceduralExtractionPrompt(patterns); + + try { + const response = await provider.summarize( + PROCEDURAL_EXTRACTION_SYSTEM, + prompt, + ); + + const procRegex = + /([\s\S]*?)<\/procedure>/g; + let match; + let newProcs = 0; + const now = new Date().toISOString(); + const existingProcs = await kv.list( + KV.procedural, + ); + + while ((match = procRegex.exec(response)) !== null) { + const name = match[1]; + const trigger = match[2]; + const stepsBlock = match[3]; + const steps: string[] = []; + + const stepRegex = /([^<]+)<\/step>/g; + let stepMatch; + while ((stepMatch = stepRegex.exec(stepsBlock)) !== null) { + steps.push(stepMatch[1].trim()); + } + + const existing = existingProcs.find( + (p) => p.name.toLowerCase() === name.toLowerCase(), + ); + if (existing) { + existing.frequency++; + existing.updatedAt = now; + existing.strength = Math.min(1, existing.strength + 0.1); + await kv.set(KV.procedural, existing.id, existing); + } else { + const proc: ProceduralMemory = { + id: generateId("proc"), + name, + steps, + triggerCondition: trigger, + frequency: 1, + sourceSessionIds: [], + strength: 0.5, + createdAt: now, + updatedAt: now, + }; + await kv.set(KV.procedural, proc.id, proc); + newProcs++; + } + } + results.procedural = { + newProcedures: newProcs, + patternsAnalyzed: patterns.length, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Procedural extraction failed", { error: msg }); + results.procedural = { error: msg }; + } + } else { + results.procedural = { + skipped: true, + reason: "fewer than 2 recurring patterns", + }; + } + } + + if (tier === "all" || tier === "decay") { + const semantic = await kv.list(KV.semantic); + applyDecay(semantic, decayDays); + for (const s of semantic) { + await kv.set(KV.semantic, s.id, s); + } + + const procedural = await kv.list(KV.procedural); + applyDecay(procedural, decayDays); + for (const p of procedural) { + await kv.set(KV.procedural, p.id, p); + } + + results.decay = { + semantic: semantic.length, + procedural: procedural.length, + }; + } + + if (process.env["OBSIDIAN_AUTO_EXPORT"] === "true") { + try { + await sdk.trigger({ function_id: "mem::obsidian-export", payload: {} }); + results.obsidianExport = { success: true }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("Obsidian auto-export failed", { error: msg }); + results.obsidianExport = { success: false, error: msg }; + } + } + + await recordAudit(kv, "consolidate", "mem::consolidate-pipeline", [], { + tier, + results, + }); + + logger.info("Consolidation pipeline complete", { tier, results }); + return { success: true, results }; + }, + ); +} diff --git a/src/functions/context.ts b/src/functions/context.ts new file mode 100644 index 0000000..1e6102c --- /dev/null +++ b/src/functions/context.ts @@ -0,0 +1,234 @@ +import type { ISdk } from "iii-sdk"; +import type { + Session, + CompressedObservation, + SessionSummary, + ContextBlock, + ProjectProfile, + MemorySlot, + Lesson, +} from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAccessBatch } from "./access-tracker.js"; +import { logger } from "../logger.js"; +import { + isSlotsEnabled, + listPinnedSlots, + renderPinnedContext, +} from "./slots.js"; + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 3); +} + +function escapeXmlAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +export function registerContextFunction( + sdk: ISdk, + kv: StateKV, + tokenBudget: number, +): void { + sdk.registerFunction("mem::context", + async (data: { sessionId: string; project: string; budget?: number }) => { + const budget = data.budget || tokenBudget; + const blocks: ContextBlock[] = []; + + const [pinnedSlots, profile, lessons] = await Promise.all([ + isSlotsEnabled() + ? listPinnedSlots(kv).catch(() => [] as MemorySlot[]) + : Promise.resolve([] as MemorySlot[]), + kv + .get(KV.profiles, data.project) + .catch(() => null), + kv.list(KV.lessons).catch(() => [] as Lesson[]), + ]); + + const slotContent = renderPinnedContext(pinnedSlots); + if (slotContent) { + blocks.push({ + type: "memory", + content: slotContent, + tokens: estimateTokens(slotContent), + recency: Date.now(), + }); + } + if (profile) { + const profileParts = []; + if (profile.topConcepts.length > 0) { + profileParts.push( + `Concepts: ${profile.topConcepts + .slice(0, 8) + .map((c) => c.concept) + .join(", ")}`, + ); + } + if (profile.topFiles.length > 0) { + profileParts.push( + `Key files: ${profile.topFiles + .slice(0, 5) + .map((f) => f.file) + .join(", ")}`, + ); + } + if (profile.conventions.length > 0) { + profileParts.push(`Conventions: ${profile.conventions.join("; ")}`); + } + if (profile.commonErrors.length > 0) { + profileParts.push( + `Common errors: ${profile.commonErrors.slice(0, 3).join("; ")}`, + ); + } + if (profileParts.length > 0) { + const profileContent = `## Project Profile\n${profileParts.join("\n")}`; + blocks.push({ + type: "memory", + content: profileContent, + tokens: estimateTokens(profileContent), + recency: new Date(profile.updatedAt).getTime(), + }); + } + } + + // Lessons — closes the loop opened by mem::lesson-save / mem::reflect. + // Without this block, lessons sit in KV and only surface when the agent + // thinks to call memory_lesson_recall. Ranking puts project-scoped + // lessons ahead of global ones, then weights by confidence; we cap at + // 10 to keep the block bounded since the outer token-budget loop + // below will drop the whole block if it doesn't fit. #457. + const relevantLessons = lessons + .filter((l) => !l.deleted && (!l.project || l.project === data.project)) + .sort((a, b) => { + const scoreA = (a.project === data.project ? 1.5 : 1) * a.confidence; + const scoreB = (b.project === data.project ? 1.5 : 1) * b.confidence; + return scoreB - scoreA; + }) + .slice(0, 10); + + if (relevantLessons.length > 0) { + const items = relevantLessons + .map( + (l) => + `- (${l.confidence.toFixed(2)}) ${l.content}${l.context ? ` — ${l.context}` : ""}`, + ) + .join("\n"); + const lessonsContent = `## Lessons Learned\n${items}`; + const mostRecent = relevantLessons.reduce((acc, l) => { + const t = new Date(l.lastReinforcedAt || l.updatedAt).getTime(); + return t > acc ? t : acc; + }, 0); + blocks.push({ + type: "memory", + content: lessonsContent, + tokens: estimateTokens(lessonsContent), + recency: mostRecent, + sourceIds: relevantLessons.map((l) => l.id), + }); + } + + const allSessions = await kv.list(KV.sessions); + const sessions = allSessions + .filter((s) => s.project === data.project && s.id !== data.sessionId) + .sort( + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ) + .slice(0, 10); + + const summariesPerSession = await Promise.all( + sessions.map((s) => + kv.get(KV.summaries, s.id).catch(() => null), + ), + ); + + const sessionsNeedingObs: number[] = []; + for (let i = 0; i < sessions.length; i++) { + const summary = summariesPerSession[i]; + if (summary) { + const content = `## ${summary.title}\n${summary.narrative}\nDecisions: ${summary.keyDecisions.join("; ")}\nFiles: ${summary.filesModified.join(", ")}`; + blocks.push({ + type: "summary", + content, + tokens: estimateTokens(content), + recency: new Date(summary.createdAt).getTime(), + }); + } else { + sessionsNeedingObs.push(i); + } + } + + const obsResults = await Promise.all( + sessionsNeedingObs.map((i) => + kv + .list(KV.observations(sessions[i].id)) + .catch(() => []), + ), + ); + + for (let j = 0; j < sessionsNeedingObs.length; j++) { + const i = sessionsNeedingObs[j]; + const observations = obsResults[j]; + const important = observations.filter( + (o) => o.title && o.importance >= 5, + ); + + if (important.length > 0) { + const top = important + .sort((a, b) => b.importance - a.importance) + .slice(0, 5); + const items = top + .map((o) => `- [${o.type}] ${o.title}: ${o.narrative}`) + .join("\n"); + const content = `## Session ${sessions[i].id.slice(0, 8)} (${sessions[i].startedAt})\n${items}`; + blocks.push({ + type: "observation", + content, + tokens: estimateTokens(content), + recency: new Date(sessions[i].startedAt).getTime(), + sourceIds: top.map((o) => o.id), + }); + } + } + + blocks.sort((a, b) => b.recency - a.recency); + + let usedTokens = 0; + const selected: string[] = []; + const accessedIds: string[] = []; + const header = ``; + const footer = ``; + usedTokens += estimateTokens(header) + estimateTokens(footer); + + for (const block of blocks) { + if (usedTokens + block.tokens > budget) continue; + selected.push(block.content); + usedTokens += block.tokens; + if (block.sourceIds && block.sourceIds.length > 0) { + accessedIds.push(...block.sourceIds); + } + } + + if (accessedIds.length > 0) { + void recordAccessBatch(kv, accessedIds); + } + + if (selected.length === 0) { + logger.info("No context available", { project: data.project }); + return { context: "", blocks: 0, tokens: 0 }; + } + + const result = `${header}\n${selected.join("\n\n")}\n${footer}`; + logger.info("Context generated", { + blocks: selected.length, + tokens: usedTokens, + }); + return { context: result, blocks: selected.length, tokens: usedTokens }; + }, + ); +} diff --git a/src/functions/crystallize.ts b/src/functions/crystallize.ts new file mode 100644 index 0000000..1f0b843 --- /dev/null +++ b/src/functions/crystallize.ts @@ -0,0 +1,294 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import type { Action, ActionEdge, Crystal, MemoryProvider } from "../types.js"; + +interface CrystalDigest { + narrative: string; + keyOutcomes: string[]; + filesAffected: string[]; + lessons: string[]; +} + +const CRYSTALLIZE_SYSTEM = `You are summarizing a completed chain of agent actions into a compact digest. +Extract: (1) what was accomplished in 1-2 sentences, (2) key decisions as bullet points, +(3) files affected, (4) any lessons or patterns worth remembering. +Return as JSON: { "narrative": "...", "keyOutcomes": ["..."], "filesAffected": ["..."], "lessons": ["..."] }`; + +export function registerCrystallizeFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::crystallize", + async (data: { + actionIds: string[]; + sessionId?: string; + project?: string; + }) => { + if (!data.actionIds || data.actionIds.length === 0) { + return { success: false, error: "actionIds is required" }; + } + + const actions: Action[] = []; + for (const id of data.actionIds) { + const action = await kv.get(KV.actions, id); + if (!action) { + return { success: false, error: `action not found: ${id}` }; + } + if (action.status !== "done" && action.status !== "cancelled") { + return { + success: false, + error: `action ${id} has status "${action.status}", expected "done" or "cancelled"`, + }; + } + actions.push(action); + } + + const allEdges = await kv.list(KV.actionEdges); + const idSet = new Set(data.actionIds); + const relevantEdges = allEdges.filter( + (e) => idSet.has(e.sourceActionId) || idSet.has(e.targetActionId), + ); + + const prompt = buildChainText(actions, relevantEdges); + + try { + const response = await provider.summarize(CRYSTALLIZE_SYSTEM, prompt); + const digest = parseDigest(response); + + const crystal: Crystal = { + id: generateId("crys"), + narrative: digest.narrative, + keyOutcomes: digest.keyOutcomes, + filesAffected: digest.filesAffected, + lessons: digest.lessons, + sourceActionIds: data.actionIds, + sessionId: data.sessionId, + project: data.project, + createdAt: new Date().toISOString(), + }; + + await kv.set(KV.crystals, crystal.id, crystal); + + await Promise.all( + digest.lessons.map((lesson) => + sdk + .trigger({ + function_id: "mem::lesson-save", + payload: { + content: lesson, + context: crystal.narrative, + confidence: 0.6, + project: data.project, + tags: [], + source: "crystal", + sourceIds: [crystal.id], + }, + }) + .catch(() => {}), + ), + ); + + for (const action of actions) { + const updated = { ...action, crystallizedInto: crystal.id }; + await kv.set(KV.actions, action.id, updated); + } + + return { success: true, crystal }; + } catch (err) { + return { + success: false, + error: `crystallization failed: ${String(err)}`, + }; + } + }, + ); + + sdk.registerFunction("mem::crystal-list", + async (data: { + project?: string; + sessionId?: string; + limit?: number; + }) => { + const limit = data.limit ?? 20; + let crystals = await kv.list(KV.crystals); + + if (data.project) { + crystals = crystals.filter((c) => c.project === data.project); + } + if (data.sessionId) { + crystals = crystals.filter((c) => c.sessionId === data.sessionId); + } + + crystals.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return { success: true, crystals: crystals.slice(0, limit) }; + }, + ); + + sdk.registerFunction("mem::crystal-get", + async (data: { crystalId: string }) => { + if (!data.crystalId) { + return { success: false, error: "crystalId is required" }; + } + + const crystal = await kv.get(KV.crystals, data.crystalId); + if (!crystal) { + return { success: false, error: "crystal not found" }; + } + + return { success: true, crystal }; + }, + ); + + sdk.registerFunction("mem::auto-crystallize", + async (data: { + olderThanDays?: number; + project?: string; + dryRun?: boolean; + }) => { + const olderThanDays = data.olderThanDays ?? 7; + const dryRun = data.dryRun ?? false; + const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000; + + let allActions = await kv.list(KV.actions); + + allActions = allActions.filter( + (a) => + a.status === "done" && + !a.crystallizedInto && + new Date(a.createdAt).getTime() < cutoff, + ); + + if (data.project) { + allActions = allActions.filter((a) => a.project === data.project); + } + + if (allActions.length === 0) { + return { success: true, groupCount: 0, crystalIds: [] }; + } + + const groups = new Map(); + for (const action of allActions) { + const key = action.parentId ?? action.project ?? "_ungrouped"; + const group = groups.get(key); + if (group) { + group.push(action); + } else { + groups.set(key, [action]); + } + } + + if (dryRun) { + const groupSummaries = Array.from(groups.entries()).map( + ([key, actions]) => ({ + groupKey: key, + actionCount: actions.length, + actionIds: actions.map((a) => a.id), + }), + ); + return { + success: true, + dryRun: true, + groupCount: groups.size, + groups: groupSummaries, + crystalIds: [], + }; + } + + const crystalIds: string[] = []; + for (const [, groupActions] of groups) { + const actionIds = groupActions.map((a) => a.id); + const project = groupActions[0].project; + + try { + const result = (await sdk.trigger({ function_id: "mem::crystallize", payload: { + actionIds, + project, + } })) as { success: boolean; crystal?: Crystal }; + + if (result.success && result.crystal) { + crystalIds.push(result.crystal.id); + } + } catch { + continue; + } + } + + return { + success: true, + groupCount: groups.size, + crystalIds, + }; + }, + ); +} + +function buildChainText(actions: Action[], edges: ActionEdge[]): string { + const lines: string[] = ["## Completed Action Chain\n"]; + + const sorted = [...actions].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + for (const action of sorted) { + lines.push(`### ${action.title}`); + if (action.description) lines.push(action.description); + if (action.result) lines.push(`Result: ${action.result}`); + lines.push( + `Tags: ${(action.tags ?? []).join(", ")}`, + ); + lines.push(""); + } + + if (edges.length > 0) { + lines.push("## Dependencies"); + for (const edge of edges) { + lines.push( + `- ${edge.sourceActionId} --${edge.type}--> ${edge.targetActionId}`, + ); + } + } + + return lines.join("\n"); +} + +function parseDigest(response: string): CrystalDigest { + try { + const jsonMatch = response.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + return { + narrative: response, + keyOutcomes: [], + filesAffected: [], + lessons: [], + }; + } + const parsed = JSON.parse(jsonMatch[0]) as Record; + return { + narrative: + typeof parsed.narrative === "string" ? parsed.narrative : response, + keyOutcomes: Array.isArray(parsed.keyOutcomes) + ? (parsed.keyOutcomes as string[]) + : [], + filesAffected: Array.isArray(parsed.filesAffected) + ? (parsed.filesAffected as string[]) + : [], + lessons: Array.isArray(parsed.lessons) + ? (parsed.lessons as string[]) + : [], + }; + } catch { + return { + narrative: response, + keyOutcomes: [], + filesAffected: [], + lessons: [], + }; + } +} diff --git a/src/functions/dedup.ts b/src/functions/dedup.ts new file mode 100644 index 0000000..23f044a --- /dev/null +++ b/src/functions/dedup.ts @@ -0,0 +1,57 @@ +import { createHash } from "node:crypto"; + +const TTL_MS = 5 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 60_000; + +interface DedupEntry { + hash: string; + expiresAt: number; +} + +export class DedupMap { + private entries = new Map(); + private cleanupTimer: ReturnType; + + constructor() { + this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS); + this.cleanupTimer.unref(); + } + + computeHash(sessionId: string, toolName: string, toolInput: unknown): string { + const input = + typeof toolInput === "string" + ? toolInput.slice(0, 500) + : JSON.stringify(toolInput ?? "").slice(0, 500); + const raw = `${sessionId}:${toolName}:${input}`; + return createHash("sha256").update(raw).digest("hex"); + } + + isDuplicate(hash: string): boolean { + const entry = this.entries.get(hash); + if (!entry) return false; + if (Date.now() > entry.expiresAt) { + this.entries.delete(hash); + return false; + } + return true; + } + + record(hash: string): void { + this.entries.set(hash, { hash, expiresAt: Date.now() + TTL_MS }); + } + + private cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.entries) { + if (now > entry.expiresAt) this.entries.delete(key); + } + } + + stop(): void { + clearInterval(this.cleanupTimer); + } + + get size(): number { + return this.entries.size; + } +} diff --git a/src/functions/diagnostics.ts b/src/functions/diagnostics.ts new file mode 100644 index 0000000..cc98288 --- /dev/null +++ b/src/functions/diagnostics.ts @@ -0,0 +1,1063 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { recordAudit } from "./audit.js"; +import type { + Action, + ActionEdge, + DiagnosticCheck, + Insight, + Lease, + Lesson, + Checkpoint, + Crystal, + ProceduralMemory, + SemanticMemory, + SessionSummary, + Signal, + Sentinel, + Sketch, + MeshPeer, + Session, + Memory, +} from "../types.js"; + +const ALL_CATEGORIES = [ + "actions", + "leases", + "sentinels", + "sketches", + "signals", + "sessions", + "memories", + "lessons", + "summaries", + "semantic", + "procedural", + "crystals", + "insights", + "mesh", +]; + +const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; +const ONE_HOUR_MS = 60 * 60 * 1000; + +export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::diagnose", + async (data: { categories?: string[] }) => { + const categories = data.categories && data.categories.length > 0 + ? data.categories.filter((c) => ALL_CATEGORIES.includes(c)) + : ALL_CATEGORIES; + + const checks: DiagnosticCheck[] = []; + const now = Date.now(); + + if (categories.includes("actions")) { + const actions = await kv.list(KV.actions); + const allEdges = await kv.list(KV.actionEdges); + const leases = await kv.list(KV.leases); + const actionMap = new Map(actions.map((a) => [a.id, a])); + + for (const action of actions) { + if (action.status === "active") { + const hasActiveLease = leases.some( + (l) => + l.actionId === action.id && + l.status === "active" && + new Date(l.expiresAt).getTime() > now, + ); + if (!hasActiveLease) { + checks.push({ + name: `active-no-lease:${action.id}`, + category: "actions", + status: "warn", + message: `Action "${action.title}" is active but has no active lease`, + fixable: false, + }); + } + } + + if (action.status === "blocked") { + const deps = allEdges.filter( + (e) => e.sourceActionId === action.id && e.type === "requires", + ); + if (deps.length > 0) { + const allDone = deps.every((d) => { + const target = actionMap.get(d.targetActionId); + return target && target.status === "done"; + }); + if (allDone) { + checks.push({ + name: `blocked-deps-done:${action.id}`, + category: "actions", + status: "fail", + message: `Action "${action.title}" is blocked but all dependencies are done`, + fixable: true, + }); + } + } + } + + if (action.status === "pending") { + const deps = allEdges.filter( + (e) => e.sourceActionId === action.id && e.type === "requires", + ); + if (deps.length > 0) { + const hasUnsatisfied = deps.some((d) => { + const target = actionMap.get(d.targetActionId); + return !target || target.status !== "done"; + }); + if (hasUnsatisfied) { + checks.push({ + name: `pending-unsatisfied-deps:${action.id}`, + category: "actions", + status: "fail", + message: `Action "${action.title}" is pending but has unsatisfied dependencies`, + fixable: true, + }); + } + } + } + } + + if ( + !checks.some((c) => c.category === "actions" && c.status !== "pass") + ) { + checks.push({ + name: "actions-ok", + category: "actions", + status: "pass", + message: `All ${actions.length} actions are consistent`, + fixable: false, + }); + } + } + + if (categories.includes("leases")) { + const leases = await kv.list(KV.leases); + const actions = await kv.list(KV.actions); + const actionIds = new Set(actions.map((a) => a.id)); + let leaseIssues = 0; + + for (const lease of leases) { + if ( + lease.status === "active" && + new Date(lease.expiresAt).getTime() <= now + ) { + checks.push({ + name: `expired-lease:${lease.id}`, + category: "leases", + status: "fail", + message: `Lease ${lease.id} for action ${lease.actionId} expired at ${lease.expiresAt}`, + fixable: true, + }); + leaseIssues++; + } + + if (!actionIds.has(lease.actionId)) { + checks.push({ + name: `orphaned-lease:${lease.id}`, + category: "leases", + status: "fail", + message: `Lease ${lease.id} references non-existent action ${lease.actionId}`, + fixable: true, + }); + leaseIssues++; + } + } + + if (leaseIssues === 0) { + checks.push({ + name: "leases-ok", + category: "leases", + status: "pass", + message: `All ${leases.length} leases are healthy`, + fixable: false, + }); + } + } + + if (categories.includes("sentinels")) { + const sentinels = await kv.list(KV.sentinels); + const actions = await kv.list(KV.actions); + const actionIds = new Set(actions.map((a) => a.id)); + let sentinelIssues = 0; + + for (const sentinel of sentinels) { + if ( + sentinel.status === "watching" && + sentinel.expiresAt && + new Date(sentinel.expiresAt).getTime() <= now + ) { + checks.push({ + name: `expired-sentinel:${sentinel.id}`, + category: "sentinels", + status: "fail", + message: `Sentinel "${sentinel.name}" expired at ${sentinel.expiresAt}`, + fixable: true, + }); + sentinelIssues++; + } + + for (const actionId of sentinel.linkedActionIds) { + if (!actionIds.has(actionId)) { + checks.push({ + name: `sentinel-missing-action:${sentinel.id}:${actionId}`, + category: "sentinels", + status: "warn", + message: `Sentinel "${sentinel.name}" references non-existent action ${actionId}`, + fixable: false, + }); + sentinelIssues++; + } + } + } + + if (sentinelIssues === 0) { + checks.push({ + name: "sentinels-ok", + category: "sentinels", + status: "pass", + message: `All ${sentinels.length} sentinels are healthy`, + fixable: false, + }); + } + } + + if (categories.includes("sketches")) { + const sketches = await kv.list(KV.sketches); + let sketchIssues = 0; + + for (const sketch of sketches) { + if ( + sketch.status === "active" && + new Date(sketch.expiresAt).getTime() <= now + ) { + checks.push({ + name: `expired-sketch:${sketch.id}`, + category: "sketches", + status: "fail", + message: `Sketch "${sketch.title}" expired at ${sketch.expiresAt}`, + fixable: true, + }); + sketchIssues++; + } + } + + if (sketchIssues === 0) { + checks.push({ + name: "sketches-ok", + category: "sketches", + status: "pass", + message: `All ${sketches.length} sketches are healthy`, + fixable: false, + }); + } + } + + if (categories.includes("signals")) { + const signals = await kv.list(KV.signals); + let signalIssues = 0; + + for (const signal of signals) { + if ( + signal.expiresAt && + new Date(signal.expiresAt).getTime() <= now + ) { + checks.push({ + name: `expired-signal:${signal.id}`, + category: "signals", + status: "fail", + message: `Signal from "${signal.from}" expired at ${signal.expiresAt}`, + fixable: true, + }); + signalIssues++; + } + } + + if (signalIssues === 0) { + checks.push({ + name: "signals-ok", + category: "signals", + status: "pass", + message: `All ${signals.length} signals are healthy`, + fixable: false, + }); + } + } + + if (categories.includes("sessions")) { + const sessions = await kv.list(KV.sessions); + let sessionIssues = 0; + + for (const session of sessions) { + if ( + session.status === "active" && + now - new Date(session.startedAt).getTime() > TWENTY_FOUR_HOURS_MS + ) { + checks.push({ + name: `abandoned-session:${session.id}`, + category: "sessions", + status: "warn", + message: `Session ${session.id} has been active for over 24 hours`, + fixable: false, + }); + sessionIssues++; + } + } + + if (sessionIssues === 0) { + checks.push({ + name: "sessions-ok", + category: "sessions", + status: "pass", + message: `All ${sessions.length} sessions are healthy`, + fixable: false, + }); + } + } + + if (categories.includes("memories")) { + const memories = await kv.list(KV.memories); + const memoryIds = new Set(memories.map((m) => m.id)); + const supersededBy = new Map(); + let memoryIssues = 0; + + for (const memory of memories) { + if (memory.supersedes && memory.supersedes.length > 0) { + for (const sid of memory.supersedes) { + if (!memoryIds.has(sid)) { + checks.push({ + name: `memory-missing-supersedes:${memory.id}:${sid}`, + category: "memories", + status: "warn", + message: `Memory "${memory.title}" supersedes non-existent memory ${sid}`, + fixable: false, + }); + memoryIssues++; + } + supersededBy.set(sid, memory.id); + } + } + } + + for (const memory of memories) { + if (memory.isLatest && supersededBy.has(memory.id)) { + checks.push({ + name: `memory-stale-latest:${memory.id}`, + category: "memories", + status: "fail", + message: `Memory "${memory.title}" has isLatest=true but is superseded by ${supersededBy.get(memory.id)}`, + fixable: true, + }); + memoryIssues++; + } + } + + // Project-coverage check: unscoped memories (no project field) will + // appear in every project's context and search results until the + // infer-memory-projects migration runs. Surface a count so operators + // know the backfill is still pending and can trigger it explicitly. + const latestMemories = memories.filter((m) => m.isLatest); + const unscopedCount = latestMemories.filter((m) => !m.project).length; + if (unscopedCount === 0) { + checks.push({ + name: "memory-project-coverage", + category: "memories", + status: "pass", + message: `All ${latestMemories.length} latest memories have a project scope`, + fixable: false, + }); + } else if (unscopedCount <= 10) { + checks.push({ + name: "memory-project-coverage", + category: "memories", + status: "warn", + message: `${unscopedCount} of ${latestMemories.length} latest memories have no project scope — run POST /agentmemory/migrate {"step":"infer-memory-projects"} to backfill`, + fixable: true, + }); + } else { + checks.push({ + name: "memory-project-coverage", + category: "memories", + status: "fail", + message: `${unscopedCount} of ${latestMemories.length} latest memories have no project scope — run POST /agentmemory/migrate {"step":"infer-memory-projects"} to backfill`, + fixable: true, + }); + } + + if (memoryIssues === 0) { + checks.push({ + name: "memories-ok", + category: "memories", + status: "pass", + message: `All ${memories.length} memories are structurally consistent`, + fixable: false, + }); + } + } + + if (categories.includes("lessons")) { + // Counts only live lessons (deleted=true rows are tombstoned). + // Catches bad confidence values that would silently break recall + // scoring (memory_lesson_recall multiplies by confidence). + const lessons = await kv.list(KV.lessons); + const live = lessons.filter((l) => !l.deleted); + let lessonIssues = 0; + for (const l of live) { + // Number.isFinite rejects NaN / Infinity / non-numbers; a + // corrupted row passing those would silently survive the < / > + // range check (e.g. NaN < 0 is false, NaN > 1 is false, so the + // bad row would be "healthy") and skew memory_lesson_recall's + // scoring downstream. Surface as warning. + if ( + !Number.isFinite(l.confidence) || + l.confidence < 0 || + l.confidence > 1 + ) { + checks.push({ + name: `lesson-bad-confidence:${l.id}`, + category: "lessons", + status: "warn", + message: `Lesson ${l.id} has confidence ${l.confidence} (expected finite number in 0..1)`, + fixable: false, + }); + lessonIssues++; + } + } + if (lessonIssues === 0) { + checks.push({ + name: "lessons-ok", + category: "lessons", + status: "pass", + message: `All ${live.length} lessons are healthy (${lessons.length - live.length} tombstoned)`, + fixable: false, + }); + } + } + + if (categories.includes("summaries")) { + const summaries = await kv.list(KV.summaries); + let summaryIssues = 0; + for (const s of summaries) { + // typeof guard before .trim() — a corrupted row with title=null + // or title=42 would otherwise throw and abort the whole diagnose + // run before later categories get checked. + if (typeof s.title !== "string" || s.title.trim().length === 0) { + checks.push({ + name: `summary-missing-title:${s.sessionId}`, + category: "summaries", + status: "warn", + message: `Summary for session ${s.sessionId} has no title`, + fixable: false, + }); + summaryIssues++; + } + } + if (summaryIssues === 0) { + checks.push({ + name: "summaries-ok", + category: "summaries", + status: "pass", + message: `All ${summaries.length} session summaries are consistent`, + fixable: false, + }); + } + } + + if (categories.includes("semantic")) { + const semantic = await kv.list(KV.semantic); + let semanticIssues = 0; + for (const s of semantic) { + if ( + !Number.isFinite(s.confidence) || + s.confidence < 0 || + s.confidence > 1 + ) { + checks.push({ + name: `semantic-bad-confidence:${s.id}`, + category: "semantic", + status: "warn", + message: `Semantic fact ${s.id} has confidence ${s.confidence} (expected finite number in 0..1)`, + fixable: false, + }); + semanticIssues++; + } + } + if (semanticIssues === 0) { + checks.push({ + name: "semantic-ok", + category: "semantic", + status: "pass", + message: `All ${semantic.length} semantic memories are consistent`, + fixable: false, + }); + } + } + + if (categories.includes("procedural")) { + const procedural = await kv.list(KV.procedural); + let proceduralIssues = 0; + for (const p of procedural) { + if (!Array.isArray(p.steps) || p.steps.length === 0) { + checks.push({ + name: `procedural-empty-steps:${p.id}`, + category: "procedural", + status: "warn", + message: `Procedural memory "${p.name}" (${p.id}) has no steps`, + fixable: false, + }); + proceduralIssues++; + } + } + if (proceduralIssues === 0) { + checks.push({ + name: "procedural-ok", + category: "procedural", + status: "pass", + message: `All ${procedural.length} procedural memories are consistent`, + fixable: false, + }); + } + } + + if (categories.includes("crystals")) { + const crystals = await kv.list(KV.crystals); + let crystalIssues = 0; + for (const c of crystals) { + if (typeof c.narrative !== "string" || c.narrative.trim().length === 0) { + checks.push({ + name: `crystal-empty-narrative:${c.id}`, + category: "crystals", + status: "warn", + message: `Crystal ${c.id} has empty narrative`, + fixable: false, + }); + crystalIssues++; + } + } + if (crystalIssues === 0) { + checks.push({ + name: "crystals-ok", + category: "crystals", + status: "pass", + message: `All ${crystals.length} crystals are consistent`, + fixable: false, + }); + } + } + + if (categories.includes("insights")) { + const insights = await kv.list(KV.insights); + let insightIssues = 0; + for (const i of insights) { + if ( + !Number.isFinite(i.confidence) || + i.confidence < 0 || + i.confidence > 1 + ) { + checks.push({ + name: `insight-bad-confidence:${i.id}`, + category: "insights", + status: "warn", + message: `Insight ${i.id} has confidence ${i.confidence} (expected finite number in 0..1)`, + fixable: false, + }); + insightIssues++; + } + } + if (insightIssues === 0) { + checks.push({ + name: "insights-ok", + category: "insights", + status: "pass", + message: `All ${insights.length} insights are consistent`, + fixable: false, + }); + } + } + + if (categories.includes("mesh")) { + const peers = await kv.list(KV.mesh); + let meshIssues = 0; + + for (const peer of peers) { + if ( + peer.lastSyncAt && + now - new Date(peer.lastSyncAt).getTime() > ONE_HOUR_MS + ) { + checks.push({ + name: `stale-peer:${peer.id}`, + category: "mesh", + status: "warn", + message: `Peer "${peer.name}" last synced over 1 hour ago`, + fixable: false, + }); + meshIssues++; + } + + if (peer.status === "error") { + checks.push({ + name: `error-peer:${peer.id}`, + category: "mesh", + status: "warn", + message: `Peer "${peer.name}" is in error state`, + fixable: false, + }); + meshIssues++; + } + } + + if (meshIssues === 0) { + checks.push({ + name: "mesh-ok", + category: "mesh", + status: "pass", + message: `All ${peers.length} mesh peers are healthy`, + fixable: false, + }); + } + } + + const summary = { + pass: checks.filter((c) => c.status === "pass").length, + warn: checks.filter((c) => c.status === "warn").length, + fail: checks.filter((c) => c.status === "fail").length, + fixable: checks.filter((c) => c.fixable).length, + }; + + return { success: true, checks, summary }; + }, + ); + + sdk.registerFunction("mem::heal", + async (data: { categories?: string[]; dryRun?: boolean }) => { + const dryRun = data.dryRun ?? false; + const categories = data.categories && data.categories.length > 0 + ? data.categories.filter((c) => ALL_CATEGORIES.includes(c)) + : ALL_CATEGORIES; + + let fixed = 0; + let skipped = 0; + const details: string[] = []; + const now = Date.now(); + + if (categories.includes("actions")) { + const actions = await kv.list(KV.actions); + const allEdges = await kv.list(KV.actionEdges); + const actionMap = new Map(actions.map((a) => [a.id, a])); + + for (const action of actions) { + if (action.status === "blocked") { + const deps = allEdges.filter( + (e) => e.sourceActionId === action.id && e.type === "requires", + ); + if (deps.length > 0) { + const allDone = deps.every((d) => { + const target = actionMap.get(d.targetActionId); + return target && target.status === "done"; + }); + if (allDone) { + if (dryRun) { + details.push( + `[dry-run] Would unblock action "${action.title}" (${action.id})`, + ); + fixed++; + continue; + } + const didFix = await withKeyedLock( + `mem:action:${action.id}`, + async () => { + const fresh = await kv.get(KV.actions, action.id); + if (!fresh || fresh.status !== "blocked") return false; + const freshEdges = await kv.list(KV.actionEdges); + const freshDeps = freshEdges.filter( + (e) => + e.sourceActionId === fresh.id && e.type === "requires", + ); + const freshActions = await kv.list(KV.actions); + const freshMap = new Map( + freshActions.map((a) => [a.id, a]), + ); + const stillAllDone = freshDeps.every((d) => { + const target = freshMap.get(d.targetActionId); + return target && target.status === "done"; + }); + if (!stillAllDone) return false; + fresh.status = "pending"; + fresh.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, fresh.id, fresh); + await recordAudit(kv, "heal", "mem::heal", [fresh.id], { + reason: "blocked-deps-done", + previousStatus: "blocked", + newStatus: "pending", + }); + return true; + }, + ); + if (didFix) { + details.push( + `Unblocked action "${action.title}" (${action.id})`, + ); + fixed++; + } else { + skipped++; + } + } + } + } + + if (action.status === "pending") { + const deps = allEdges.filter( + (e) => e.sourceActionId === action.id && e.type === "requires", + ); + if (deps.length > 0) { + const hasUnsatisfied = deps.some((d) => { + const target = actionMap.get(d.targetActionId); + return !target || target.status !== "done"; + }); + if (hasUnsatisfied) { + if (dryRun) { + details.push( + `[dry-run] Would block action "${action.title}" (${action.id})`, + ); + fixed++; + continue; + } + const didFix = await withKeyedLock( + `mem:action:${action.id}`, + async () => { + const fresh = await kv.get(KV.actions, action.id); + if (!fresh || fresh.status !== "pending") return false; + const freshEdges = await kv.list(KV.actionEdges); + const freshDeps = freshEdges.filter( + (e) => + e.sourceActionId === fresh.id && e.type === "requires", + ); + const freshActions = await kv.list(KV.actions); + const freshMap = new Map( + freshActions.map((a) => [a.id, a]), + ); + const stillUnsatisfied = freshDeps.some((d) => { + const target = freshMap.get(d.targetActionId); + return !target || target.status !== "done"; + }); + if (!stillUnsatisfied) return false; + fresh.status = "blocked"; + fresh.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, fresh.id, fresh); + await recordAudit(kv, "heal", "mem::heal", [fresh.id], { + reason: "pending-unsatisfied-deps", + previousStatus: "pending", + newStatus: "blocked", + }); + return true; + }, + ); + if (didFix) { + details.push( + `Blocked action "${action.title}" (${action.id})`, + ); + fixed++; + } else { + skipped++; + } + } + } + } + } + } + + if (categories.includes("leases")) { + const leases = await kv.list(KV.leases); + const actions = await kv.list(KV.actions); + const actionIds = new Set(actions.map((a) => a.id)); + + for (const lease of leases) { + if ( + lease.status === "active" && + new Date(lease.expiresAt).getTime() <= now + ) { + if (dryRun) { + details.push( + `[dry-run] Would expire lease ${lease.id} for action ${lease.actionId}`, + ); + fixed++; + continue; + } + const didFix = await withKeyedLock( + `mem:action:${lease.actionId}`, + async () => { + const fresh = await kv.get(KV.leases, lease.id); + if ( + !fresh || + fresh.status !== "active" || + new Date(fresh.expiresAt).getTime() > Date.now() + ) { + return false; + } + fresh.status = "expired"; + await kv.set(KV.leases, fresh.id, fresh); + await recordAudit(kv, "heal", "mem::heal", [fresh.id], { + entityType: "lease", + reason: "expired-lease", + newStatus: "expired", + }); + + const action = await kv.get(KV.actions, fresh.actionId); + if ( + action && + action.status === "active" && + action.assignedTo === fresh.agentId + ) { + action.status = "pending"; + action.assignedTo = undefined; + action.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "heal", "mem::heal", [action.id], { + entityType: "action", + reason: "release-expired-lease", + newStatus: "pending", + }); + } + return true; + }, + ); + if (didFix) { + details.push( + `Expired lease ${lease.id} for action ${lease.actionId}`, + ); + fixed++; + } else { + skipped++; + } + continue; + } + + if (!actionIds.has(lease.actionId)) { + if (dryRun) { + details.push( + `[dry-run] Would delete orphaned lease ${lease.id}`, + ); + fixed++; + continue; + } + await kv.delete(KV.leases, lease.id); + await recordAudit(kv, "heal", "mem::heal", [lease.id], { + entityType: "lease", + reason: "orphaned-lease", + action: "delete", + }); + details.push(`Deleted orphaned lease ${lease.id}`); + fixed++; + } + } + } + + if (categories.includes("sentinels")) { + const sentinels = await kv.list(KV.sentinels); + + for (const sentinel of sentinels) { + if ( + sentinel.status === "watching" && + sentinel.expiresAt && + new Date(sentinel.expiresAt).getTime() <= now + ) { + if (dryRun) { + details.push( + `[dry-run] Would expire sentinel "${sentinel.name}" (${sentinel.id})`, + ); + fixed++; + continue; + } + const didFix = await withKeyedLock( + `mem:sentinel:${sentinel.id}`, + async () => { + const fresh = await kv.get( + KV.sentinels, + sentinel.id, + ); + if (!fresh || fresh.status !== "watching") return false; + if ( + !fresh.expiresAt || + new Date(fresh.expiresAt).getTime() > Date.now() + ) { + return false; + } + fresh.status = "expired"; + await kv.set(KV.sentinels, fresh.id, fresh); + await recordAudit(kv, "heal", "mem::heal", [fresh.id], { + entityType: "sentinel", + reason: "expired-sentinel", + newStatus: "expired", + }); + return true; + }, + ); + if (didFix) { + details.push( + `Expired sentinel "${sentinel.name}" (${sentinel.id})`, + ); + fixed++; + } else { + skipped++; + } + } + } + } + + if (categories.includes("sketches")) { + const sketches = await kv.list(KV.sketches); + + for (const sketch of sketches) { + if ( + sketch.status === "active" && + new Date(sketch.expiresAt).getTime() <= now + ) { + if (dryRun) { + details.push( + `[dry-run] Would discard expired sketch "${sketch.title}" (${sketch.id})`, + ); + fixed++; + continue; + } + const didFix = await withKeyedLock( + `mem:sketch:${sketch.id}`, + async () => { + const fresh = await kv.get(KV.sketches, sketch.id); + if ( + !fresh || + fresh.status !== "active" || + new Date(fresh.expiresAt).getTime() > Date.now() + ) { + return false; + } + + const allEdges = await kv.list(KV.actionEdges); + const actionIdSet = new Set(fresh.actionIds); + for (const edge of allEdges) { + if ( + actionIdSet.has(edge.sourceActionId) || + actionIdSet.has(edge.targetActionId) + ) { + await kv.delete(KV.actionEdges, edge.id); + await recordAudit(kv, "heal", "mem::heal", [edge.id], { + entityType: "actionEdge", + reason: "sketch-gc-discard", + action: "delete", + }); + } + } + for (const actionId of fresh.actionIds) { + await kv.delete(KV.actions, actionId); + await recordAudit(kv, "heal", "mem::heal", [actionId], { + entityType: "action", + reason: "sketch-gc-discard", + action: "delete", + }); + } + + fresh.status = "discarded"; + fresh.discardedAt = new Date().toISOString(); + await kv.set(KV.sketches, fresh.id, fresh); + await recordAudit(kv, "heal", "mem::heal", [fresh.id], { + entityType: "sketch", + reason: "expired-sketch", + newStatus: "discarded", + }); + return true; + }, + ); + if (didFix) { + details.push( + `Discarded expired sketch "${sketch.title}" (${sketch.id})`, + ); + fixed++; + } else { + skipped++; + } + } + } + } + + if (categories.includes("signals")) { + const signals = await kv.list(KV.signals); + + for (const signal of signals) { + if ( + signal.expiresAt && + new Date(signal.expiresAt).getTime() <= now + ) { + if (dryRun) { + details.push( + `[dry-run] Would delete expired signal ${signal.id}`, + ); + fixed++; + continue; + } + await kv.delete(KV.signals, signal.id); + await recordAudit(kv, "heal", "mem::heal", [signal.id], { + entityType: "signal", + reason: "expired-signal", + action: "delete", + }); + details.push(`Deleted expired signal ${signal.id}`); + fixed++; + } + } + } + + if (categories.includes("memories")) { + const memories = await kv.list(KV.memories); + const supersededBy = new Map(); + + for (const memory of memories) { + if (memory.supersedes && memory.supersedes.length > 0) { + for (const sid of memory.supersedes) { + supersededBy.set(sid, memory.id); + } + } + } + + for (const memory of memories) { + if (memory.isLatest && supersededBy.has(memory.id)) { + if (dryRun) { + details.push( + `[dry-run] Would set isLatest=false on memory "${memory.title}" (${memory.id})`, + ); + fixed++; + continue; + } + const didFix = await withKeyedLock( + `mem:memory:${memory.id}`, + async () => { + const fresh = await kv.get(KV.memories, memory.id); + if (!fresh || !fresh.isLatest) return false; + fresh.isLatest = false; + fresh.updatedAt = new Date().toISOString(); + await kv.set(KV.memories, fresh.id, fresh); + await recordAudit(kv, "heal", "mem::heal", [fresh.id], { + entityType: "memory", + reason: "superseded-memory-mark-non-latest", + action: "update", + }); + return true; + }, + ); + if (didFix) { + details.push( + `Set isLatest=false on memory "${memory.title}" (${memory.id})`, + ); + fixed++; + } else { + skipped++; + } + } + } + } + + return { success: true, fixed, skipped, details }; + }, + ); +} diff --git a/src/functions/disk-size-manager.ts b/src/functions/disk-size-manager.ts new file mode 100644 index 0000000..0905e83 --- /dev/null +++ b/src/functions/disk-size-manager.ts @@ -0,0 +1,44 @@ +import { TriggerAction, type ISdk } from "iii-sdk"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { getMaxBytes } from "../utils/image-store.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { logger } from "../logger.js"; +import type { StateScope, StateScopeKey } from "../types.js"; + +const DISK_SIZE_KEY: StateScopeKey = "system:currentDiskSize"; + +export function registerDiskSizeManager(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction( + "mem::disk-size-delta", + async (data: { deltaBytes: number }) => { + if (typeof data?.deltaBytes !== "number" || !isFinite(data.deltaBytes)) { + return { success: false, error: "deltaBytes must be a finite number" }; + } + + return withKeyedLock(DISK_SIZE_KEY, async () => { + const currentTotal = + (await kv.get(KV.state, DISK_SIZE_KEY)) || 0; + let newTotal = currentTotal + data.deltaBytes; + + if (newTotal < 0) newTotal = 0; + + await kv.set(KV.state, DISK_SIZE_KEY, newTotal); + + if (data.deltaBytes > 0 && newTotal > getMaxBytes()) { + sdk.trigger({ + function_id: "mem::image-quota-cleanup", + payload: {}, + action: TriggerAction.Void(), + }); + logger.info("Disk quota exceeded, cleanup triggered", { + currentBytes: newTotal, + maxBytes: getMaxBytes(), + }); + } + + return { success: true, currentTotal: newTotal }; + }); + }, + ); +} diff --git a/src/functions/enrich.ts b/src/functions/enrich.ts new file mode 100644 index 0000000..032ec97 --- /dev/null +++ b/src/functions/enrich.ts @@ -0,0 +1,139 @@ +import type { ISdk } from "iii-sdk"; +import type { Memory } from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { logger } from "../logger.js"; + +const MAX_CONTEXT_LENGTH = 4000; + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::enrich", + async (data: { + sessionId: string; + files: string[]; + terms?: string[]; + toolName?: string; + project?: string; + }) => { + const project = + typeof data.project === "string" && data.project.trim().length > 0 + ? data.project.trim() + : undefined; + + const parts: string[] = []; + + const fileContextPromise = sdk + .trigger<{ sessionId: string; files: string[] }, { context: string }>({ + function_id: "mem::file-context", + payload: { + sessionId: data.sessionId, + files: data.files, + }, + }) + .catch(() => ({ context: "" })); + + const searchQueries: string[] = [ + ...data.files.map((f) => f.split("/").pop() || f), + ...(data.terms || []), + ].filter((q) => q.length > 0); + + const searchPromise = + searchQueries.length > 0 + ? sdk + .trigger< + { query: string; limit: number; project?: string }, + { results: Array<{ observation: { narrative: string } }> } + >({ + function_id: "mem::search", + payload: { + query: searchQueries.join(" "), + limit: 5, + ...(project !== undefined && { project }), + }, + }) + .catch(() => ({ results: [] })) + : Promise.resolve({ results: [] }); + + const bugMemoriesPromise = kv + .list(KV.memories) + .then((memories) => + memories + .filter( + (m) => + m.type === "bug" && + m.isLatest && + // Guard only when both sides have an explicit project; unscoped memories pass through. + (!project || !m.project || m.project === project) && + m.files.some((f) => + data.files.some((df) => f.includes(df) || df.includes(f)), + ), + ) + .sort( + (a, b) => + new Date(b.updatedAt || b.createdAt).getTime() - + new Date(a.updatedAt || a.createdAt).getTime(), + ), + ) + .catch(() => []); + + const [fileContext, searchResult, bugMemories] = await Promise.all([ + fileContextPromise, + searchPromise, + bugMemoriesPromise, + ]); + + if (fileContext.context) { + parts.push(fileContext.context); + } + + if (searchResult.results.length > 0) { + const observations = searchResult.results + .map((r) => r.observation?.narrative) + .filter(Boolean) + .map((n) => escapeXml(n as string)) + .join("\n"); + if (observations) { + parts.push( + `\n${observations}\n`, + ); + } + } + + if (bugMemories.length > 0) { + const bugs = bugMemories + .slice(0, 3) + .map((m) => `- ${escapeXml(m.title)}: ${escapeXml(m.content)}`) + .join("\n"); + parts.push( + `\n${bugs}\n`, + ); + } + + let context = parts.join("\n\n"); + let truncated = false; + if (context.length > MAX_CONTEXT_LENGTH) { + context = context.slice(0, MAX_CONTEXT_LENGTH); + truncated = true; + } + + logger.info("Enrichment completed", { + sessionId: data.sessionId, + project, + fileCount: data.files.length, + contextLength: context.length, + truncated, + }); + + return { context, truncated }; + }, + ); +} diff --git a/src/functions/evict.ts b/src/functions/evict.ts new file mode 100644 index 0000000..626f4ff --- /dev/null +++ b/src/functions/evict.ts @@ -0,0 +1,347 @@ +import type { ISdk } from "iii-sdk"; +import type { + Session, + CompressedObservation, + RawObservation, + SessionSummary, + Memory, +} from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { deleteAccessLog } from "./access-tracker.js"; +import { logger } from "../logger.js"; + +interface EvictionConfig { + staleSessionDays: number; + lowImportanceMaxDays: number; + lowImportanceThreshold: number; + maxObservationsPerProject: number; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +const DEFAULTS: EvictionConfig = { + staleSessionDays: 30, + lowImportanceMaxDays: 90, + lowImportanceThreshold: 3, + maxObservationsPerProject: 10_000, +}; + +interface EvictionStats { + staleSessions: number; + lowImportanceObs: number; + capEvictions: number; + expiredMemories: number; + nonLatestMemories: number; + dryRun: boolean; +} + +function isValidRecoveryResult(result: unknown): boolean { + if (!result || typeof result !== "object") return false; + if (!("success" in result)) return true; + return (result as { success?: unknown }).success !== false; +} + +function isCompressedObservation( + observation: CompressedObservation | RawObservation, +): observation is CompressedObservation { + return ( + "title" in observation && + typeof observation.title === "string" && + observation.title.length > 0 + ); +} + +async function recoverStaleSession( + sdk: ISdk, + sessionId: string, +): Promise { + try { + const result = await sdk.trigger({ + function_id: "event::session::stopped", + payload: { sessionId }, + }); + if (!isValidRecoveryResult(result)) { + logger.warn("Stale session recovery failed", { + sessionId, + result, + }); + return false; + } + return true; + } catch (err) { + logger.warn("Stale session recovery failed", { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} + +async function runRecoveredSessionConsolidation(sdk: ISdk): Promise { + try { + await sdk.trigger({ + function_id: "mem::consolidate-pipeline", + payload: { tier: "all" }, + }); + } catch (err) { + logger.warn("Recovered session consolidation failed", { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +export function registerEvictFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::evict", + async (data: { dryRun?: boolean }): Promise => { + const dryRun = data?.dryRun ?? false; + const { decrementImageRef } = await import("./image-refs.js"); + + const configOverride = await kv + .get>(KV.config, "eviction") + .catch(() => null); + const cfg = { ...DEFAULTS, ...configOverride }; + + const now = Date.now(); + const stats: EvictionStats = { + staleSessions: 0, + lowImportanceObs: 0, + capEvictions: 0, + expiredMemories: 0, + nonLatestMemories: 0, + dryRun, + }; + + let recoveredStaleSessions = 0; + const sessions = await kv.list(KV.sessions).catch(() => []); + const summaries = await kv + .list(KV.summaries) + .catch(() => []); + const summaryIds = new Set(summaries.map((s) => s.sessionId)); + + for (const session of sessions) { + if (!session.startedAt) continue; + const age = now - new Date(session.startedAt).getTime(); + const staleDays = cfg.staleSessionDays * MS_PER_DAY; + if (age > staleDays && !summaryIds.has(session.id)) { + if (dryRun) { + stats.staleSessions++; + } else { + const observations = await kv + .list( + KV.observations(session.id), + ) + .catch((err) => { + logger.warn("Stale session observation scan failed", { + sessionId: session.id, + error: err instanceof Error ? err.message : String(err), + }); + return null; + }); + if (!observations) continue; + + let recovered = false; + const hasCompressedObservations = observations.some( + isCompressedObservation, + ); + if (hasCompressedObservations) { + recovered = await recoverStaleSession(sdk, session.id); + if (!recovered) continue; + recoveredStaleSessions++; + } else if (observations.length > 0) { + logger.warn("Stale session has no compressed observations", { + sessionId: session.id, + }); + continue; + } + + try { + await kv.delete(KV.sessions, session.id); + stats.staleSessions++; + } catch (err) { + logger.warn("Eviction delete failed", { + resource: "session", + id: session.id, + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + await recordAudit(kv, "delete", "mem::evict", [session.id], { + resource: "session", + reason: recovered + ? "stale_session_recovered_then_evicted" + : "stale_session_without_summary", + dryRun, + }); + } + } + } + + if (!dryRun && recoveredStaleSessions > 0) { + await runRecoveredSessionConsolidation(sdk); + } + + const projectObs = new Map(); + for (const session of sessions) { + const obs = await kv + .list(KV.observations(session.id)) + .catch(() => []); + const compressed = obs.filter((o) => o.title); + + for (const o of compressed) { + if (!o.timestamp) continue; + const age = now - new Date(o.timestamp).getTime(); + const maxAge = cfg.lowImportanceMaxDays * MS_PER_DAY; + if ( + age > maxAge && + (o.importance ?? 5) < cfg.lowImportanceThreshold + ) { + if (dryRun) { + stats.lowImportanceObs++; + } else { + try { + await kv.delete(KV.observations(session.id), o.id); + stats.lowImportanceObs++; + } catch (err) { + logger.warn("Eviction delete failed", { + resource: "observation", + id: o.id, + sessionId: session.id, + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (o.imageData) await decrementImageRef(kv, sdk, o.imageData); + if (o.imageRef && o.imageRef !== o.imageData) await decrementImageRef(kv, sdk, o.imageRef); + await recordAudit(kv, "delete", "mem::evict", [o.id], { + resource: "observation", + reason: "low_importance_old_observation", + sessionId: session.id, + dryRun, + }); + } + } + } + + const project = session.project || "unknown"; + const existing = projectObs.get(project) || []; + existing.push(...compressed); + projectObs.set(project, existing); + } + + for (const [, obs] of projectObs) { + if (obs.length > cfg.maxObservationsPerProject) { + const sorted = obs.sort( + (a, b) => (a.importance ?? 5) - (b.importance ?? 5), + ); + const toEvict = sorted.slice( + 0, + obs.length - cfg.maxObservationsPerProject, + ); + if (dryRun) { + stats.capEvictions += toEvict.length; + } else { + for (const o of toEvict) { + try { + await kv.delete(KV.observations(o.sessionId), o.id); + stats.capEvictions++; + } catch (err) { + logger.warn("Eviction delete failed", { + resource: "observation", + id: o.id, + sessionId: o.sessionId, + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (o.imageData) await decrementImageRef(kv, sdk, o.imageData); + if (o.imageRef && o.imageRef !== o.imageData) await decrementImageRef(kv, sdk, o.imageRef); + await recordAudit(kv, "delete", "mem::evict", [o.id], { + resource: "observation", + reason: "project_observation_cap", + sessionId: o.sessionId, + dryRun, + }); + } + } + } + } + + const memories = await kv.list(KV.memories).catch(() => []); + const evictedMemIds = new Set(); + for (const mem of memories) { + if (mem.forgetAfter) { + const expiry = new Date(mem.forgetAfter).getTime(); + if (now > expiry) { + if (dryRun) { + stats.expiredMemories++; + evictedMemIds.add(mem.id); + } else { + try { + await kv.delete(KV.memories, mem.id); + stats.expiredMemories++; + evictedMemIds.add(mem.id); + } catch (err) { + logger.warn("Eviction delete failed", { + resource: "memory", + id: mem.id, + reason: "expired_memory", + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (mem.imageRef) { + await decrementImageRef(kv, sdk, mem.imageRef); + } + await recordAudit(kv, "delete", "mem::evict", [mem.id], { + resource: "memory", + reason: "expired_memory", + dryRun, + }); + await deleteAccessLog(kv, mem.id); + } + } + } + + if ( + !evictedMemIds.has(mem.id) && + mem.isLatest === false && + mem.createdAt + ) { + const age = now - new Date(mem.createdAt).getTime(); + if (age > cfg.lowImportanceMaxDays * MS_PER_DAY) { + if (dryRun) { + stats.nonLatestMemories++; + } else { + try { + await kv.delete(KV.memories, mem.id); + stats.nonLatestMemories++; + } catch (err) { + logger.warn("Eviction delete failed", { + resource: "memory", + id: mem.id, + reason: "old_non_latest_memory", + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + if (mem.imageRef) { + await decrementImageRef(kv, sdk, mem.imageRef); + } + await recordAudit(kv, "delete", "mem::evict", [mem.id], { + resource: "memory", + reason: "old_non_latest_memory", + dryRun, + }); + await deleteAccessLog(kv, mem.id); + } + } + } + } + + logger.info("Eviction complete", { stats }); + return stats; + }, + ); +} diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts new file mode 100644 index 0000000..327117b --- /dev/null +++ b/src/functions/export-import.ts @@ -0,0 +1,587 @@ +import type { ISdk } from "iii-sdk"; +import type { + Session, + CompressedObservation, + Memory, + SessionSummary, + ProjectProfile, + ExportData, + GraphNode, + GraphEdge, + SemanticMemory, + ProceduralMemory, + Action, + ActionEdge, + Routine, + Signal, + Checkpoint, + Sentinel, + Sketch, + Crystal, + Facet, + Lesson, + Insight, + ExportPagination, + AccessLogExport, +} from "../types.js"; +import { normalizeAccessLog } from "./access-tracker.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { VERSION } from "../version.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::export", + async (data?: { maxSessions?: number; offset?: number }) => { + const rawMax = Number(data?.maxSessions); + const maxSessions = Number.isFinite(rawMax) && rawMax > 0 ? Math.min(Math.floor(rawMax), 1000) : undefined; + const rawOffset = Number(data?.offset); + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0; + + const allSessions = await kv.list(KV.sessions); + const paginatedSessions = maxSessions !== undefined + ? allSessions.slice(offset, offset + maxSessions) + : allSessions; + const memories = await kv.list(KV.memories); + const summaries = await kv.list(KV.summaries); + + const observations: Record = {}; + const obsResults = await Promise.all( + paginatedSessions.map((session) => + kv + .list(KV.observations(session.id)) + .catch(() => [] as CompressedObservation[]) + .then((obs) => ({ sessionId: session.id, obs })), + ), + ); + for (const { sessionId, obs } of obsResults) { + if (obs.length > 0) { + observations[sessionId] = obs; + } + } + + const profiles: ProjectProfile[] = []; + const uniqueProjects = [...new Set(paginatedSessions.map((s) => s.project))]; + const profileResults = await Promise.all( + uniqueProjects.map((project) => + kv.get(KV.profiles, project).catch(() => null), + ), + ); + for (const profile of profileResults) { + if (profile) profiles.push(profile); + } + + const [ + graphNodes, + graphEdges, + semanticMemories, + proceduralMemories, + actions, + actionEdges, + sentinels, + sketches, + crystals, + facets, + lessons, + insights, + routines, + signals, + checkpoints, + accessLogs, + ] = await Promise.all([ + kv.list(KV.graphNodes).catch(() => []), + kv.list(KV.graphEdges).catch(() => []), + kv.list(KV.semantic).catch(() => []), + kv.list(KV.procedural).catch(() => []), + kv.list(KV.actions).catch(() => []), + kv.list(KV.actionEdges).catch(() => []), + kv.list(KV.sentinels).catch(() => []), + kv.list(KV.sketches).catch(() => []), + kv.list(KV.crystals).catch(() => []), + kv.list(KV.facets).catch(() => []), + kv.list(KV.lessons).catch(() => []), + kv.list(KV.insights).catch(() => []), + kv.list(KV.routines).catch(() => []), + kv.list(KV.signals).catch(() => []), + kv.list(KV.checkpoints).catch(() => []), + kv.list(KV.accessLog).catch(() => []), + ]); + + const exportData: ExportData = { + version: VERSION, + exportedAt: new Date().toISOString(), + sessions: paginatedSessions, + observations, + memories, + summaries, + profiles: profiles.length > 0 ? profiles : undefined, + graphNodes: graphNodes.length > 0 ? graphNodes : undefined, + graphEdges: graphEdges.length > 0 ? graphEdges : undefined, + semanticMemories: + semanticMemories.length > 0 ? semanticMemories : undefined, + proceduralMemories: + proceduralMemories.length > 0 ? proceduralMemories : undefined, + actions: actions.length > 0 ? actions : undefined, + actionEdges: actionEdges.length > 0 ? actionEdges : undefined, + sentinels: sentinels.length > 0 ? sentinels : undefined, + sketches: sketches.length > 0 ? sketches : undefined, + crystals: crystals.length > 0 ? crystals : undefined, + facets: facets.length > 0 ? facets : undefined, + lessons: lessons.length > 0 ? lessons : undefined, + insights: insights.length > 0 ? insights : undefined, + routines: routines.length > 0 ? routines : undefined, + signals: signals.length > 0 ? signals : undefined, + checkpoints: checkpoints.length > 0 ? checkpoints : undefined, + accessLogs: accessLogs.length > 0 ? accessLogs : undefined, + }; + + if (maxSessions !== undefined) { + exportData.pagination = { + offset, + limit: maxSessions, + total: allSessions.length, + hasMore: offset + maxSessions < allSessions.length, + }; + } + + const totalObs = Object.values(observations).reduce( + (sum, arr) => sum + arr.length, + 0, + ); + logger.info("Export complete", { + sessions: paginatedSessions.length, + totalSessions: allSessions.length, + observations: totalObs, + memories: memories.length, + summaries: summaries.length, + }); + + return exportData; + }, + ); + + sdk.registerFunction("mem::import", + async (data: { + exportData: ExportData; + strategy?: "merge" | "replace" | "skip"; + }) => { + if ( + !data?.exportData || + typeof data.exportData !== "object" || + typeof (data.exportData as { version?: unknown }).version !== "string" + ) { + return { success: false, error: "exportData with string version is required" }; + } + const strategy = data.strategy || "merge"; + const importData = data.exportData; + + const supportedVersions = new Set(["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.6.1", "0.7.0", "0.7.2", "0.7.3", "0.7.4", "0.7.5", "0.7.6", "0.7.7", "0.7.9", "0.8.0", "0.8.1", "0.8.2", "0.8.3", "0.8.4", "0.8.5", "0.8.6", "0.8.7", "0.8.8", "0.8.9", "0.8.10", "0.8.11", "0.8.12", "0.8.13", "0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "0.9.5", "0.9.6", "0.9.7", "0.9.8", "0.9.9", "0.9.10", "0.9.11", "0.9.12", "0.9.13", "0.9.14", "0.9.15", "0.9.16", "0.9.17", "0.9.18", "0.9.19", "0.9.20", "0.9.21", "0.9.22", "0.9.23", "0.9.24", "0.9.25", "0.9.26", "0.9.27"]); + if (!supportedVersions.has(importData.version)) { + return { + success: false, + error: `Unsupported export version: ${importData.version}`, + }; + } + + const MAX_SESSIONS = 10_000; + const MAX_MEMORIES = 50_000; + const MAX_SUMMARIES = 10_000; + const MAX_OBS_PER_SESSION = 5_000; + const MAX_TOTAL_OBSERVATIONS = 500_000; + const MAX_ACCESS_LOGS = 50_000; + + if (!Array.isArray(importData.sessions)) { + return { success: false, error: "sessions must be an array" }; + } + if (!Array.isArray(importData.memories)) { + return { success: false, error: "memories must be an array" }; + } + if (!Array.isArray(importData.summaries)) { + return { success: false, error: "summaries must be an array" }; + } + if ( + typeof importData.observations !== "object" || + importData.observations === null || + Array.isArray(importData.observations) + ) { + return { success: false, error: "observations must be an object" }; + } + + if (importData.sessions.length > MAX_SESSIONS) { + return { + success: false, + error: `Too many sessions (max ${MAX_SESSIONS})`, + }; + } + if (importData.memories.length > MAX_MEMORIES) { + return { + success: false, + error: `Too many memories (max ${MAX_MEMORIES})`, + }; + } + if (importData.summaries.length > MAX_SUMMARIES) { + return { + success: false, + error: `Too many summaries (max ${MAX_SUMMARIES})`, + }; + } + const MAX_OBS_BUCKETS = 10_000; + const obsBuckets = Object.keys(importData.observations); + if (obsBuckets.length > MAX_OBS_BUCKETS) { + return { + success: false, + error: `Too many observation buckets (max ${MAX_OBS_BUCKETS})`, + }; + } + + let totalObservations = 0; + for (const [, obs] of Object.entries(importData.observations)) { + if (!Array.isArray(obs)) { + return { success: false, error: "observation values must be arrays" }; + } + if (obs.length > MAX_OBS_PER_SESSION) { + return { + success: false, + error: `Too many observations per session (max ${MAX_OBS_PER_SESSION})`, + }; + } + totalObservations += obs.length; + } + if (totalObservations > MAX_TOTAL_OBSERVATIONS) { + return { + success: false, + error: `Too many total observations (max ${MAX_TOTAL_OBSERVATIONS})`, + }; + } + + const stats = { + sessions: 0, + observations: 0, + memories: 0, + summaries: 0, + skipped: 0, + }; + + if (strategy === "replace") { + const existing = await kv.list(KV.sessions); + for (const session of existing) { + await kv.delete(KV.sessions, session.id); + const obs = await kv + .list(KV.observations(session.id)) + .catch(() => []); + for (const o of obs) { + await kv.delete(KV.observations(session.id), o.id); + } + } + const existingMem = await kv.list(KV.memories); + for (const m of existingMem) { + await kv.delete(KV.memories, m.id); + } + const existingSummaries = await kv.list(KV.summaries); + for (const s of existingSummaries) { + await kv.delete(KV.summaries, s.sessionId); + } + for (const a of await kv.list(KV.actions).catch(() => [])) { + await kv.delete(KV.actions, a.id); + } + for (const e of await kv.list(KV.actionEdges).catch(() => [])) { + await kv.delete(KV.actionEdges, e.id); + } + for (const r of await kv.list(KV.routines).catch(() => [])) { + await kv.delete(KV.routines, r.id); + } + for (const s of await kv.list(KV.signals).catch(() => [])) { + await kv.delete(KV.signals, s.id); + } + for (const c of await kv.list(KV.checkpoints).catch(() => [])) { + await kv.delete(KV.checkpoints, c.id); + } + for (const s of await kv.list(KV.sentinels).catch(() => [])) { + await kv.delete(KV.sentinels, s.id); + } + for (const s of await kv.list(KV.sketches).catch(() => [])) { + await kv.delete(KV.sketches, s.id); + } + for (const c of await kv.list(KV.crystals).catch(() => [])) { + await kv.delete(KV.crystals, c.id); + } + for (const f of await kv.list(KV.facets).catch(() => [])) { + await kv.delete(KV.facets, f.id); + } + for (const l of await kv.list(KV.lessons).catch(() => [])) { + await kv.delete(KV.lessons, l.id); + } + for (const i of await kv.list(KV.insights).catch(() => [])) { + await kv.delete(KV.insights, i.id); + } + for (const n of await kv.list<{ id: string }>(KV.graphNodes).catch(() => [])) { + await kv.delete(KV.graphNodes, n.id); + } + for (const e of await kv.list<{ id: string }>(KV.graphEdges).catch(() => [])) { + await kv.delete(KV.graphEdges, e.id); + } + for (const s of await kv.list<{ id: string }>(KV.semantic).catch(() => [])) { + await kv.delete(KV.semantic, s.id); + } + for (const p of await kv.list<{ id: string }>(KV.procedural).catch(() => [])) { + await kv.delete(KV.procedural, p.id); + } + for (const profile of await kv.list(KV.profiles).catch(() => [])) { + await kv.delete(KV.profiles, profile.project); + } + for (const a of await kv.list(KV.accessLog).catch(() => [])) { + await kv.delete(KV.accessLog, a.memoryId); + } + } + + for (const session of importData.sessions) { + if (strategy === "skip") { + const existing = await kv + .get(KV.sessions, session.id) + .catch(() => null); + if (existing) { + stats.skipped++; + continue; + } + } + await kv.set(KV.sessions, session.id, session); + stats.sessions++; + } + + for (const [sessionId, obs] of Object.entries(importData.observations)) { + for (const o of obs) { + if (strategy === "skip") { + const existing = await kv + .get(KV.observations(sessionId), o.id) + .catch(() => null); + if (existing) { + stats.skipped++; + continue; + } + } + await kv.set(KV.observations(sessionId), o.id, o); + stats.observations++; + } + } + + for (const memory of importData.memories) { + if (strategy === "skip") { + const existing = await kv + .get(KV.memories, memory.id) + .catch(() => null); + if (existing) { + stats.skipped++; + continue; + } + } + // Older exports + hand-edited dumps can omit this field. + if (!Array.isArray(memory.sessionIds)) { + memory.sessionIds = []; + } + await kv.set(KV.memories, memory.id, memory); + stats.memories++; + } + + for (const summary of importData.summaries) { + if (strategy === "skip") { + const existing = await kv + .get(KV.summaries, summary.sessionId) + .catch(() => null); + if (existing) { + stats.skipped++; + continue; + } + } + await kv.set(KV.summaries, summary.sessionId, summary); + stats.summaries++; + } + + if (importData.graphNodes) { + for (const node of importData.graphNodes) { + if (strategy === "skip") { + const existing = await kv.get(KV.graphNodes, node.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.graphNodes, node.id, node); + } + } + if (importData.graphEdges) { + for (const edge of importData.graphEdges) { + if (strategy === "skip") { + const existing = await kv.get(KV.graphEdges, edge.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.graphEdges, edge.id, edge); + } + } + if (importData.semanticMemories) { + for (const sem of importData.semanticMemories) { + if (strategy === "skip") { + const existing = await kv.get(KV.semantic, sem.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.semantic, sem.id, sem); + } + } + if (importData.proceduralMemories) { + for (const proc of importData.proceduralMemories) { + if (strategy === "skip") { + const existing = await kv.get(KV.procedural, proc.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.procedural, proc.id, proc); + } + } + if (importData.profiles) { + for (const profile of importData.profiles) { + if (strategy === "skip") { + const existing = await kv + .get(KV.profiles, profile.project) + .catch(() => null); + if (existing) { + stats.skipped++; + continue; + } + } + await kv.set(KV.profiles, profile.project, profile); + } + } + + if (importData.actions) { + for (const action of importData.actions) { + if (strategy === "skip") { + const existing = await kv.get(KV.actions, action.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.actions, action.id, action); + } + } + if (importData.actionEdges) { + for (const edge of importData.actionEdges) { + if (strategy === "skip") { + const existing = await kv.get(KV.actionEdges, edge.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.actionEdges, edge.id, edge); + } + } + if (importData.routines) { + for (const routine of importData.routines) { + if (strategy === "skip") { + const existing = await kv.get(KV.routines, routine.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.routines, routine.id, routine); + } + } + if (importData.signals) { + for (const signal of importData.signals) { + if (strategy === "skip") { + const existing = await kv.get(KV.signals, signal.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.signals, signal.id, signal); + } + } + if (importData.checkpoints) { + for (const checkpoint of importData.checkpoints) { + if (strategy === "skip") { + const existing = await kv.get(KV.checkpoints, checkpoint.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.checkpoints, checkpoint.id, checkpoint); + } + } + if (importData.sentinels) { + for (const sentinel of importData.sentinels) { + if (strategy === "skip") { + const existing = await kv.get(KV.sentinels, sentinel.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.sentinels, sentinel.id, sentinel); + } + } + if (importData.sketches) { + for (const sketch of importData.sketches) { + if (strategy === "skip") { + const existing = await kv.get(KV.sketches, sketch.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.sketches, sketch.id, sketch); + } + } + if (importData.crystals) { + for (const crystal of importData.crystals) { + if (strategy === "skip") { + const existing = await kv.get(KV.crystals, crystal.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.crystals, crystal.id, crystal); + } + } + if (importData.facets) { + for (const facet of importData.facets) { + if (strategy === "skip") { + const existing = await kv.get(KV.facets, facet.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.facets, facet.id, facet); + } + } + if (importData.lessons) { + for (const lesson of importData.lessons) { + if (strategy === "skip") { + const existing = await kv.get(KV.lessons, lesson.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.lessons, lesson.id, lesson); + } + } + if (importData.insights) { + for (const insight of importData.insights) { + if (strategy === "skip") { + const existing = await kv.get(KV.insights, insight.id).catch(() => null); + if (existing) { stats.skipped++; continue; } + } + await kv.set(KV.insights, insight.id, insight); + } + } + if (importData.accessLogs) { + if (!Array.isArray(importData.accessLogs)) { + return { success: false, error: "accessLogs must be an array" }; + } + if (importData.accessLogs.length > MAX_ACCESS_LOGS) { + return { + success: false, + error: `Too many access logs (max ${MAX_ACCESS_LOGS})`, + }; + } + const memoryIds = new Set( + importData.memories.map((m) => m.id), + ); + for (const raw of importData.accessLogs) { + const log = normalizeAccessLog(raw); + if (!log.memoryId || !memoryIds.has(log.memoryId)) continue; + if (strategy === "skip") { + const existing = await kv + .get(KV.accessLog, log.memoryId) + .catch(() => null); + if (existing) { + stats.skipped++; + continue; + } + } + await kv.set(KV.accessLog, log.memoryId, log); + } + } + + logger.info("Import complete", { strategy, ...stats }); + await recordAudit(kv, "import", "mem::import", [], { + strategy, + stats, + }); + return { success: true, strategy, ...stats }; + }, + ); +} diff --git a/src/functions/facets.ts b/src/functions/facets.ts new file mode 100644 index 0000000..cb5b37d --- /dev/null +++ b/src/functions/facets.ts @@ -0,0 +1,242 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import type { Facet } from "../types.js"; + +export function registerFacetsFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::facet-tag", + async (data: { + targetId: string; + targetType: string; + dimension: string; + value: string; + }) => { + if (!data.targetId || typeof data.targetId !== "string") { + return { success: false, error: "targetId is required" }; + } + + const validTypes = ["action", "memory", "observation"]; + if (!validTypes.includes(data.targetType)) { + return { + success: false, + error: `targetType must be one of: ${validTypes.join(", ")}`, + }; + } + + if ( + !data.dimension || + typeof data.dimension !== "string" || + data.dimension.trim() === "" + ) { + return { success: false, error: "dimension is required" }; + } + + if ( + !data.value || + typeof data.value !== "string" || + data.value.trim() === "" + ) { + return { success: false, error: "value is required" }; + } + + const dimension = data.dimension.trim(); + const value = data.value.trim(); + + const existing = await kv.list(KV.facets); + const duplicate = existing.find( + (f) => + f.targetId === data.targetId && + f.dimension === dimension && + f.value === value, + ); + if (duplicate) { + return { success: true, facet: duplicate, skipped: true }; + } + + const facet: Facet = { + id: generateId("fct"), + targetId: data.targetId, + targetType: data.targetType as Facet["targetType"], + dimension, + value, + createdAt: new Date().toISOString(), + }; + + await kv.set(KV.facets, facet.id, facet); + return { success: true, facet }; + }, + ); + + sdk.registerFunction("mem::facet-untag", + async (data: { + targetId: string; + dimension: string; + value?: string; + }) => { + if (!data.targetId) { + return { success: false, error: "targetId is required" }; + } + if (!data.dimension) { + return { success: false, error: "dimension is required" }; + } + + const all = await kv.list(KV.facets); + const matches = all.filter((f) => { + if (f.targetId !== data.targetId || f.dimension !== data.dimension) { + return false; + } + if (data.value !== undefined) { + return f.value === data.value; + } + return true; + }); + + for (const f of matches) { + await kv.delete(KV.facets, f.id); + } + + return { success: true, removed: matches.length }; + }, + ); + + sdk.registerFunction("mem::facet-query", + async (data: { + matchAll?: string[]; + matchAny?: string[]; + targetType?: string; + limit?: number; + }) => { + if ( + (!data.matchAll || data.matchAll.length === 0) && + (!data.matchAny || data.matchAny.length === 0) + ) { + return { + success: false, + error: "at least one of matchAll or matchAny is required", + }; + } + + const all = await kv.list(KV.facets); + const filtered = data.targetType + ? all.filter((f) => f.targetType === data.targetType) + : all; + + const targetFacetMap = new Map }>(); + for (const f of filtered) { + const key = `${f.dimension}:${f.value}`; + let entry = targetFacetMap.get(f.targetId); + if (!entry) { + entry = { targetType: f.targetType, facetKeys: new Set() }; + targetFacetMap.set(f.targetId, entry); + } + entry.facetKeys.add(key); + } + + const results: Array<{ targetId: string; targetType: string; matchedFacets: string[] }> = []; + + for (const [targetId, entry] of targetFacetMap) { + const matched: string[] = []; + + if (data.matchAll && data.matchAll.length > 0) { + const allPresent = data.matchAll.every((k) => entry.facetKeys.has(k)); + if (!allPresent) continue; + for (const k of data.matchAll) { + if (!matched.includes(k)) matched.push(k); + } + } + + if (data.matchAny && data.matchAny.length > 0) { + const anyPresent = data.matchAny.filter((k) => entry.facetKeys.has(k)); + if (anyPresent.length === 0) continue; + for (const k of anyPresent) { + if (!matched.includes(k)) matched.push(k); + } + } + + results.push({ + targetId, + targetType: entry.targetType, + matchedFacets: matched, + }); + } + + const limit = data.limit || 50; + return { success: true, results: results.slice(0, limit) }; + }, + ); + + sdk.registerFunction("mem::facet-get", + async (data: { targetId: string }) => { + if (!data.targetId) { + return { success: false, error: "targetId is required" }; + } + + const all = await kv.list(KV.facets); + const targetFacets = all.filter((f) => f.targetId === data.targetId); + + const dimMap = new Map(); + for (const f of targetFacets) { + let values = dimMap.get(f.dimension); + if (!values) { + values = []; + dimMap.set(f.dimension, values); + } + values.push(f.value); + } + + const dimensions = Array.from(dimMap.entries()).map(([dimension, values]) => ({ + dimension, + values, + })); + + return { success: true, dimensions }; + }, + ); + + sdk.registerFunction("mem::facet-stats", + async (data: { targetType?: string }) => { + const all = await kv.list(KV.facets); + const filtered = data.targetType + ? all.filter((f) => f.targetType === data.targetType) + : all; + + const dimMap = new Map>(); + for (const f of filtered) { + let valueMap = dimMap.get(f.dimension); + if (!valueMap) { + valueMap = new Map(); + dimMap.set(f.dimension, valueMap); + } + valueMap.set(f.value, (valueMap.get(f.value) || 0) + 1); + } + + const dimensions = Array.from(dimMap.entries()).map(([dimension, valueMap]) => ({ + dimension, + values: Array.from(valueMap.entries()).map(([value, count]) => ({ + value, + count, + })), + })); + + return { success: true, dimensions, totalFacets: filtered.length }; + }, + ); + + sdk.registerFunction("mem::facet-dimensions", + async () => { + const all = await kv.list(KV.facets); + + const counts = new Map(); + for (const f of all) { + counts.set(f.dimension, (counts.get(f.dimension) || 0) + 1); + } + + const dimensions = Array.from(counts.entries()).map(([dimension, count]) => ({ + dimension, + count, + })); + + return { success: true, dimensions }; + }, + ); +} diff --git a/src/functions/file-index.ts b/src/functions/file-index.ts new file mode 100644 index 0000000..a5c5031 --- /dev/null +++ b/src/functions/file-index.ts @@ -0,0 +1,134 @@ +import type { ISdk } from "iii-sdk"; +import type { CompressedObservation, Session } from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { recordAccessBatch } from "./access-tracker.js"; +import { logger } from "../logger.js"; + +interface FileHistory { + file: string; + observations: Array<{ + sessionId: string; + obsId: string; + type: string; + title: string; + narrative: string; + importance: number; + timestamp: string; + }>; +} + +export function registerFileIndexFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::file-context", + async ( + data: { sessionId?: string; files?: string[]; project?: string } | undefined, + ) => { + const sessionId = + data && typeof data.sessionId === "string" ? data.sessionId.trim() : ""; + const normalizedProject = + typeof data?.project === "string" ? data.project.trim() : undefined; + const files = Array.isArray(data?.files) + ? data!.files + .map((file) => (typeof file === "string" ? file.trim() : "")) + .filter(Boolean) + : []; + if (files.length === 0) { + await recordAudit(kv, "observe", "mem::file-context", [sessionId || "unknown"], { + error: "invalid_payload", + hasSessionId: !!sessionId, + hasProject: !!normalizedProject, + fileCount: files.length, + }); + return { context: "", files: [] }; + } + const results: FileHistory[] = []; + + const sessions = await kv.list(KV.sessions); + let otherSessions = sessionId + ? sessions.filter((s) => s.id !== sessionId) + : sessions; + if (normalizedProject) { + otherSessions = otherSessions.filter((s) => s.project === normalizedProject); + } + otherSessions = otherSessions + .sort( + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ) + .slice(0, 15); + + const obsCache = new Map(); + for (const session of otherSessions) { + obsCache.set( + session.id, + await kv.list(KV.observations(session.id)), + ); + } + + for (const file of files) { + const history: FileHistory = { file, observations: [] }; + const normalizedFile = file.replace(/^\.\//, ""); + + for (const session of otherSessions) { + const observations = obsCache.get(session.id) || []; + + for (const obs of observations) { + if (!obs.files || !obs.title) continue; + const matches = obs.files.some( + (f) => + f === file || + f === normalizedFile || + f.endsWith(`/${normalizedFile}`) || + normalizedFile.endsWith(`/${f}`), + ); + if (matches && obs.importance >= 4) { + history.observations.push({ + sessionId: session.id, + obsId: obs.id, + type: obs.type, + title: obs.title, + narrative: obs.narrative, + importance: obs.importance, + timestamp: obs.timestamp, + }); + } + } + } + + history.observations.sort((a, b) => b.importance - a.importance); + history.observations = history.observations.slice(0, 5); + + if (history.observations.length > 0) { + results.push(history); + } + } + + if (results.length === 0) { + return { context: "" }; + } + + const lines: string[] = [""]; + for (const fh of results) { + lines.push(`## ${fh.file}`); + for (const obs of fh.observations) { + lines.push(`- [${obs.type}] ${obs.title}: ${obs.narrative}`); + } + } + lines.push(""); + + const accessedIds: string[] = []; + for (const fh of results) { + for (const obs of fh.observations) accessedIds.push(obs.obsId); + } + void recordAccessBatch(kv, accessedIds); + + const context = lines.join("\n"); + logger.info("File context generated", { + files: files.length, + results: results.length, + }); + return { context }; + }, + ); +} diff --git a/src/functions/flow-compress.ts b/src/functions/flow-compress.ts new file mode 100644 index 0000000..d2eb5a3 --- /dev/null +++ b/src/functions/flow-compress.ts @@ -0,0 +1,221 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import type { Action, ActionEdge, RoutineRun, MemoryProvider } from "../types.js"; +import { recordAudit } from "./audit.js"; + +const FLOW_COMPRESS_SYSTEM = `You are a workflow summarizer. Given a completed action chain, produce a concise summary capturing: +1. The overall goal and outcome +2. Key steps taken and their results +3. Any notable decisions or discoveries +4. Lessons learned + +Output as XML: + +What was the workflow trying to achieve +What happened +Numbered list of key steps +Any new insights or discoveries +What to remember for next time +`; + +export function registerFlowCompressFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::flow-compress", + async (data: { runId?: string; actionIds?: string[]; project?: string }) => { + let actionsToCompress: Action[] = []; + + if (data.runId) { + const run = await kv.get(KV.routineRuns, data.runId); + if (!run) { + return { success: false, error: "run not found" }; + } + for (const id of run.actionIds) { + const action = await kv.get(KV.actions, id); + if (action) actionsToCompress.push(action); + } + } else if (data.actionIds && data.actionIds.length > 0) { + for (const id of data.actionIds) { + const action = await kv.get(KV.actions, id); + if (action) actionsToCompress.push(action); + } + } else if (data.project) { + const allActions = await kv.list(KV.actions); + actionsToCompress = allActions.filter( + (a) => a.project === data.project && a.status === "done", + ); + } else { + return { + success: false, + error: "runId, actionIds, or project is required", + }; + } + + const doneActions = actionsToCompress.filter( + (a) => a.status === "done", + ); + if (doneActions.length === 0) { + return { + success: true, + message: "No completed actions to compress", + compressed: 0, + }; + } + + const allEdges = await kv.list(KV.actionEdges); + const relevantIds = new Set(doneActions.map((a) => a.id)); + const relevantEdges = allEdges.filter( + (e) => + relevantIds.has(e.sourceActionId) || + relevantIds.has(e.targetActionId), + ); + + const prompt = buildFlowPrompt(doneActions, relevantEdges); + + try { + const response = await provider.summarize( + FLOW_COMPRESS_SYSTEM, + prompt, + ); + const summary = parseFlowSummary(response); + const ts = new Date().toISOString(); + + const memory = { + id: generateId("mem"), + createdAt: ts, + updatedAt: ts, + type: "workflow" as const, + title: summary.goal || `Workflow: ${doneActions.length} actions`, + content: formatSummary(summary), + concepts: extractConcepts(doneActions), + files: extractFiles(doneActions), + sessionIds: [], + strength: 1.0, + version: 1, + isLatest: true, + metadata: { + flowCompressed: true, + actionCount: doneActions.length, + actionIds: doneActions.map((a) => a.id), + }, + }; + + await kv.set(KV.memories, memory.id, memory); + await recordAudit(kv, "compress", "mem::flow-compress", [memory.id], { + action: "compress_flow", + flowCompressed: true, + actionCount: doneActions.length, + project: data.project, + }); + + return { + success: true, + compressed: doneActions.length, + memoryId: memory.id, + summary, + }; + } catch (err) { + return { + success: false, + error: `compression failed: ${String(err)}`, + compressed: 0, + }; + } + }, + ); +} + +function buildFlowPrompt( + actions: Action[], + edges: ActionEdge[], +): string { + const lines: string[] = ["## Completed Action Chain\n"]; + + const sorted = [...actions].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + for (const action of sorted) { + lines.push(`### ${action.title}`); + if (action.description) lines.push(action.description); + if (action.result) lines.push(`Result: ${action.result}`); + lines.push(`Priority: ${action.priority}, Tags: ${(action.tags ?? []).join(", ")}`); + lines.push(""); + } + + if (edges.length > 0) { + lines.push("## Dependencies"); + for (const edge of edges) { + lines.push(`- ${edge.sourceActionId} --${edge.type}--> ${edge.targetActionId}`); + } + } + + return lines.join("\n"); +} + +function parseFlowSummary(response: string): { + goal: string; + outcome: string; + steps: string; + discoveries: string; + lesson: string; +} { + const extract = (tag: string): string => { + const match = response.match( + new RegExp(`<${tag}>([\\s\\S]*?)`), + ); + return match ? match[1].trim() : ""; + }; + return { + goal: extract("goal"), + outcome: extract("outcome"), + steps: extract("steps"), + discoveries: extract("discoveries"), + lesson: extract("lesson"), + }; +} + +function formatSummary(s: { + goal: string; + outcome: string; + steps: string; + discoveries: string; + lesson: string; +}): string { + const parts: string[] = []; + if (s.goal) parts.push(`Goal: ${s.goal}`); + if (s.outcome) parts.push(`Outcome: ${s.outcome}`); + if (s.steps) parts.push(`Steps: ${s.steps}`); + if (s.discoveries) parts.push(`Discoveries: ${s.discoveries}`); + if (s.lesson) parts.push(`Lesson: ${s.lesson}`); + return parts.join("\n\n"); +} + +function extractConcepts(actions: Action[]): string[] { + const concepts = new Set(); + for (const a of actions) { + for (const tag of a.tags ?? []) { + if (!tag.startsWith("routine:")) concepts.add(tag); + } + } + return Array.from(concepts); +} + +function extractFiles(actions: Action[]): string[] { + const files = new Set(); + for (const a of actions) { + if (a.metadata && typeof a.metadata === "object") { + const meta = a.metadata as Record; + if (Array.isArray(meta.files)) { + for (const f of meta.files) { + if (typeof f === "string") files.add(f); + } + } + } + } + return Array.from(files); +} diff --git a/src/functions/frontier.ts b/src/functions/frontier.ts new file mode 100644 index 0000000..30f43fe --- /dev/null +++ b/src/functions/frontier.ts @@ -0,0 +1,194 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import type { Action, ActionEdge, Checkpoint, Lease } from "../types.js"; + +export interface FrontierItem { + action: Action; + score: number; + blockers: string[]; + leased: boolean; +} + +export function registerFrontierFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::frontier", + async (data: { + project?: string; + agentId?: string; + limit?: number; + includeLeasedByOthers?: boolean; + }) => { + const actions = await kv.list(KV.actions); + const edges = await kv.list(KV.actionEdges); + const leases = await kv.list(KV.leases); + const checkpoints = await kv.list(KV.checkpoints); + const now = Date.now(); + + const activeLeaseMap = new Map(); + for (const lease of leases) { + if ( + lease.status === "active" && + new Date(lease.expiresAt).getTime() > now + ) { + activeLeaseMap.set(lease.actionId, lease); + } + } + + const checkpointMap = new Map(); + for (const cp of checkpoints) { + checkpointMap.set(cp.id, cp); + } + + const actionMap = new Map(); + for (const a of actions) actionMap.set(a.id, a); + + const frontier: FrontierItem[] = []; + + for (const action of actions) { + if (action.status === "done" || action.status === "cancelled") continue; + if (data.project && action.project !== data.project) continue; + + const blockers: string[] = []; + const inEdges = edges.filter( + (e) => e.sourceActionId === action.id && e.type === "requires", + ); + + for (const edge of inEdges) { + const dep = actionMap.get(edge.targetActionId); + if (dep && dep.status !== "done") { + blockers.push(`requires:${dep.id}:${dep.title}`); + } + } + + const gateEdges = edges.filter( + (e) => e.sourceActionId === action.id && e.type === "gated_by", + ); + for (const edge of gateEdges) { + const cp = checkpointMap.get(edge.targetActionId); + if (cp && cp.status !== "passed") { + blockers.push(`checkpoint:${cp.id}:${cp.name}`); + } + } + + const conflictEdges = edges.filter( + (e) => + (e.sourceActionId === action.id || + e.targetActionId === action.id) && + e.type === "conflicts_with", + ); + for (const edge of conflictEdges) { + const otherId = + edge.sourceActionId === action.id + ? edge.targetActionId + : edge.sourceActionId; + const other = actionMap.get(otherId); + if (other && other.status === "active") { + blockers.push(`conflict:${other.id}:${other.title}`); + } + } + + if (blockers.length > 0) continue; + + const lease = activeLeaseMap.get(action.id); + const leasedByOther = + lease && data.agentId && lease.agentId !== data.agentId; + if (leasedByOther && !data.includeLeasedByOthers) continue; + + const score = computeScore(action, edges, now); + + frontier.push({ + action, + score, + blockers: [], + leased: !!lease, + }); + } + + frontier.sort((a, b) => b.score - a.score); + const limit = data.limit || 20; + + return { + success: true, + frontier: frontier.slice(0, limit), + totalActions: actions.length, + totalUnblocked: frontier.length, + }; + }, + ); + + sdk.registerFunction("mem::next", + async (data: { project?: string; agentId?: string }) => { + const result = await sdk.trigger< + { project?: string; agentId?: string; limit?: number }, + { + success: boolean; + frontier: FrontierItem[]; + totalActions: number; + totalUnblocked: number; + } + >({ function_id: "mem::frontier", payload: { + project: data.project, + agentId: data.agentId, + limit: 1, + } }); + + if (!result.success) { + return { + success: false, + suggestion: null, + message: "Failed to compute frontier", + totalActions: 0, + }; + } + if (result.frontier.length === 0) { + return { + success: true, + suggestion: null, + message: "No actionable work found", + totalActions: result.totalActions || 0, + }; + } + + const top = result.frontier[0]; + return { + success: true, + suggestion: { + actionId: top.action.id, + title: top.action.title, + description: top.action.description, + priority: top.action.priority, + score: top.score, + tags: top.action.tags, + }, + message: `Suggested: ${top.action.title} (priority ${top.action.priority}, score ${top.score.toFixed(2)})`, + totalActions: result.totalActions, + totalUnblocked: result.totalUnblocked, + }; + }, + ); +} + +function computeScore( + action: Action, + edges: ActionEdge[], + now: number, +): number { + let score = action.priority * 10; + + const ageHours = + (now - new Date(action.createdAt).getTime()) / (1000 * 60 * 60); + score += Math.min(ageHours * 0.5, 20); + + const unlockCount = edges.filter( + (e) => e.sourceActionId === action.id && e.type === "unlocks", + ).length; + score += unlockCount * 5; + + if (edges.some((e) => e.sourceActionId === action.id && e.type === "spawned_by")) { + score += 3; + } + + if (action.status === "active") score += 15; + + return Math.round(score * 100) / 100; +} diff --git a/src/functions/governance.ts b/src/functions/governance.ts new file mode 100644 index 0000000..16d0d1c --- /dev/null +++ b/src/functions/governance.ts @@ -0,0 +1,177 @@ +import type { ISdk } from "iii-sdk"; +import type { Memory, GovernanceFilter, AuditEntry } from "../types.js"; +import { KV } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { recordAudit, safeAudit, queryAudit } from "./audit.js"; +import { deleteAccessLog } from "./access-tracker.js"; +import { getSearchIndex, vectorIndexRemove, flushIndexSave } from "./search.js"; +import { logger } from "../logger.js"; + +export function registerGovernanceFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::governance-delete", + async (data: { memoryIds: string[]; reason?: string }) => { + if ( + !data.memoryIds || + !Array.isArray(data.memoryIds) || + data.memoryIds.length === 0 + ) { + return { success: false, error: "memoryIds array is required" }; + } + + let deleted = 0; + for (const id of data.memoryIds) { + const mem = await kv.get(KV.memories, id); + if (mem) { + await kv.delete(KV.memories, id); + await deleteAccessLog(kv, id); + getSearchIndex().remove(id); + vectorIndexRemove(id); + deleted++; + } + } + + if (deleted > 0) await flushIndexSave(); + + await recordAudit( + kv, + "delete", + "mem::governance-delete", + data.memoryIds, + { + reason: data.reason || "manual deletion", + deleted, + }, + ); + + logger.info("Governance delete", { + requested: data.memoryIds.length, + deleted, + }); + return { success: true, deleted, total: data.memoryIds.length }; + }, + ); + + sdk.registerFunction("mem::governance-bulk", + async (data: GovernanceFilter & { dryRun?: boolean }) => { + + const hasFilter = + (data.type && data.type.length > 0) || + data.dateFrom || + data.dateTo || + data.qualityBelow !== undefined; + if (!hasFilter && !data.dryRun) { + return { + success: false, + error: "At least one filter is required for non-dryRun bulk delete", + }; + } + + const memories = await kv.list(KV.memories); + let candidates = memories; + + if (data.type && data.type.length > 0) { + candidates = candidates.filter((m) => data.type!.includes(m.type)); + } + if (data.dateFrom) { + const from = new Date(data.dateFrom).getTime(); + if (Number.isNaN(from)) { + return { success: false, error: "Invalid dateFrom format" }; + } + candidates = candidates.filter( + (m) => new Date(m.createdAt).getTime() >= from, + ); + } + if (data.dateTo) { + const to = new Date(data.dateTo).getTime(); + if (Number.isNaN(to)) { + return { success: false, error: "Invalid dateTo format" }; + } + candidates = candidates.filter( + (m) => new Date(m.createdAt).getTime() <= to, + ); + } + if (data.qualityBelow !== undefined) { + candidates = candidates.filter((m) => m.strength < data.qualityBelow!); + } + + if (data.dryRun) { + return { + success: true, + dryRun: true, + wouldDelete: candidates.length, + ids: candidates.map((m) => m.id), + }; + } + + const BATCH_SIZE = 50; + const successfulIds: string[] = []; + const failures: Array<{ id: string; error: string }> = []; + for (let i = 0; i < candidates.length; i += BATCH_SIZE) { + const batch = candidates.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(async (mem) => { + await kv.delete(KV.memories, mem.id); + await deleteAccessLog(kv, mem.id); + getSearchIndex().remove(mem.id); + vectorIndexRemove(mem.id); + }), + ); + results.forEach((result, j) => { + const mem = batch[j]; + if (result.status === "fulfilled") { + successfulIds.push(mem.id); + } else { + logger.warn("Governance bulk delete failed", { + memoryId: mem.id, + error: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + }); + failures.push({ + id: mem.id, + error: "delete_failed", + }); + } + }); + } + + if (successfulIds.length > 0) await flushIndexSave(); + + await safeAudit( + kv, + "delete", + "mem::governance-bulk", + successfulIds, + { + filter: data, + deleted: successfulIds.length, + failed: failures.length, + failures: failures.length > 0 ? failures : undefined, + }, + ); + + logger.info("Governance bulk delete", { + deleted: successfulIds.length, + failed: failures.length, + }); + return { + success: failures.length === 0, + deleted: successfulIds.length, + failed: failures.length, + failures: failures.length > 0 ? failures : undefined, + }; + }, + ); + + sdk.registerFunction("mem::audit-query", + async (data?: { + operation?: AuditEntry["operation"]; + dateFrom?: string; + dateTo?: string; + limit?: number; + }) => { + return queryAudit(kv, data); + }, + ); +} diff --git a/src/functions/graph-retrieval.ts b/src/functions/graph-retrieval.ts new file mode 100644 index 0000000..10fd51a --- /dev/null +++ b/src/functions/graph-retrieval.ts @@ -0,0 +1,362 @@ +import type { + GraphNode, + GraphEdge, +} from "../types.js"; +import { KV } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; + +export interface GraphRetrievalResult { + obsId: string; + sessionId: string; + score: number; + graphContext: string; + pathLength: number; +} + +function buildGraphContext( + path: Array<{ node: GraphNode; edge?: GraphEdge }>, +): string { + const parts: string[] = []; + for (const step of path) { + const props = Object.entries(step.node.properties) + .slice(0, 3) + .map(([k, v]) => `${k}=${v}`) + .join(", "); + let line = `[${step.node.type}] ${step.node.name}`; + if (props) line += ` (${props})`; + if (step.edge) { + line += ` --${step.edge.type}-->`; + if (step.edge.context?.reasoning) { + line += ` [${step.edge.context.reasoning}]`; + } + if (step.edge.tvalid) { + line += ` @${step.edge.tvalid}`; + } + } + parts.push(line); + } + return parts.join(" "); +} + +export class GraphRetrieval { + constructor(private kv: StateKV) {} + + async searchByEntities( + entityNames: string[], + maxDepth = 2, + maxResults = 20, + ): Promise { + const allNodes = (await this.kv.list(KV.graphNodes)).filter((n) => !n.stale); + const allEdges = (await this.kv.list(KV.graphEdges)).filter((e) => !e.stale); + + const matchingNodes = allNodes.filter((n) => { + const nameLower = n.name.toLowerCase(); + return entityNames.some( + (e) => + nameLower.includes(e.toLowerCase()) || + e.toLowerCase().includes(nameLower), + ); + }); + + if (matchingNodes.length === 0) return []; + + const results: GraphRetrievalResult[] = []; + const visitedObs = new Set(); + + for (const startNode of matchingNodes) { + const paths = this.dijkstraTraversal( + startNode, + allNodes, + allEdges, + maxDepth, + ); + + for (const path of paths) { + const lastNode = path[path.length - 1].node; + for (const obsId of lastNode.sourceObservationIds) { + if (visitedObs.has(obsId)) continue; + visitedObs.add(obsId); + + const pathLength = path.length; + const edgeWeights = path + .filter((s) => s.edge) + .map((s) => s.edge!.weight); + const avgWeight = + edgeWeights.length > 0 + ? edgeWeights.reduce((a, b) => a + b, 0) / edgeWeights.length + : 0.5; + const score = avgWeight * (1 / pathLength); + + results.push({ + obsId, + sessionId: "", + score, + graphContext: buildGraphContext(path), + pathLength, + }); + } + } + + for (const obsId of startNode.sourceObservationIds) { + if (visitedObs.has(obsId)) continue; + visitedObs.add(obsId); + results.push({ + obsId, + sessionId: "", + score: 1.0, + graphContext: `[${startNode.type}] ${startNode.name}`, + pathLength: 0, + }); + } + } + + results.sort((a, b) => b.score - a.score); + return results.slice(0, maxResults); + } + + async expandFromChunks( + obsIds: string[], + maxDepth = 1, + maxResults = 10, + ): Promise { + const allNodes = (await this.kv.list(KV.graphNodes)).filter((n) => !n.stale); + const allEdges = (await this.kv.list(KV.graphEdges)).filter((e) => !e.stale); + + const linkedNodes = allNodes.filter((n) => + n.sourceObservationIds.some((id) => obsIds.includes(id)), + ); + + const results: GraphRetrievalResult[] = []; + const visitedObs = new Set(obsIds); + + for (const node of linkedNodes) { + const paths = this.dijkstraTraversal(node, allNodes, allEdges, maxDepth); + for (const path of paths) { + const lastNode = path[path.length - 1].node; + for (const obsId of lastNode.sourceObservationIds) { + if (visitedObs.has(obsId)) continue; + visitedObs.add(obsId); + + const pathLength = path.length; + const score = 0.5 * (1 / (pathLength + 1)); + + results.push({ + obsId, + sessionId: "", + score, + graphContext: buildGraphContext(path), + pathLength, + }); + } + } + } + + results.sort((a, b) => b.score - a.score); + return results.slice(0, maxResults); + } + + async temporalQuery( + entityName: string, + asOf?: string, + ): Promise<{ + entity: GraphNode | null; + currentState: GraphEdge[]; + history: GraphEdge[]; + }> { + const allNodes = (await this.kv.list(KV.graphNodes)).filter((n) => !n.stale); + const allEdges = (await this.kv.list(KV.graphEdges)).filter((e) => !e.stale); + + const entity = allNodes.find( + (n) => n.name.toLowerCase() === entityName.toLowerCase(), + ); + if (!entity) return { entity: null, currentState: [], history: [] }; + + const relatedEdges = allEdges.filter( + (e) => e.sourceNodeId === entity.id || e.targetNodeId === entity.id, + ); + + if (!asOf) { + const latestEdges = this.getLatestEdges(relatedEdges); + const historicalEdges = relatedEdges.filter( + (e) => !latestEdges.some((le) => le.id === e.id), + ); + return { entity, currentState: latestEdges, history: historicalEdges }; + } + + const asOfDate = new Date(asOf).getTime(); + const validEdges = relatedEdges.filter((e) => { + const commitDate = new Date(e.tcommit || e.createdAt).getTime(); + if (commitDate > asOfDate) return false; + if (e.tvalid) { + const validDate = new Date(e.tvalid).getTime(); + if (validDate > asOfDate) return false; + } + if (e.tvalidEnd) { + const endDate = new Date(e.tvalidEnd).getTime(); + if (endDate < asOfDate) return false; + } + return true; + }); + + return { + entity, + currentState: this.getLatestEdges(validEdges), + history: validEdges, + }; + } + + private getLatestEdges(edges: GraphEdge[]): GraphEdge[] { + const byKey = new Map(); + for (const e of edges) { + const key = `${e.sourceNodeId}|${e.targetNodeId}|${e.type}`; + if (!byKey.has(key)) byKey.set(key, []); + byKey.get(key)!.push(e); + } + + const latest: GraphEdge[] = []; + for (const group of byKey.values()) { + if (group.length === 0) continue; + group.sort( + (a, b) => + new Date(b.tcommit || b.createdAt).getTime() - + new Date(a.tcommit || a.createdAt).getTime(), + ); + const newest = group.find((e) => e.isLatest !== false) || group[0]; + latest.push(newest); + } + return latest; + } + + // Weighted shortest-path traversal (#328). Replaces the prior BFS, + // which fell back to edge-count order and ignored the 0.1-1.0 weight + // attached to every graph edge. Dijkstra over `cost = 1/weight` + // (cheaper edges = stronger relationships) returns the + // highest-weighted path to each reachable node within maxDepth. Also + // tightens the perf profile: + // - Adjacency built once in O(V+E) (previous BFS re-filtered + // allEdges per visited node, O(V·E) overall). + // - Min-heap dequeue is O(log V) per pop (previous queue.shift() + // was O(n) — the dominant cost on graphs above ~200 nodes per + // the contributor's benchmark in #328). + private dijkstraTraversal( + startNode: GraphNode, + allNodes: GraphNode[], + allEdges: GraphEdge[], + maxDepth: number, + ): Array> { + const nodeIndex = new Map(); + for (const n of allNodes) nodeIndex.set(n.id, n); + + const adjacency = new Map>(); + for (const edge of allEdges) { + const a = edge.sourceNodeId; + const b = edge.targetNodeId; + if (!adjacency.has(a)) adjacency.set(a, []); + if (!adjacency.has(b)) adjacency.set(b, []); + adjacency.get(a)!.push({ neighborId: b, edge }); + adjacency.get(b)!.push({ neighborId: a, edge }); + } + + const dist = new Map(); + const pathTo = new Map>(); + dist.set(startNode.id, 0); + pathTo.set(startNode.id, [{ node: startNode }]); + + const heap = new MinHeap<{ nodeId: string; depth: number; cost: number }>( + (a, b) => a.cost - b.cost, + ); + heap.push({ nodeId: startNode.id, depth: 0, cost: 0 }); + + while (heap.size() > 0) { + const { nodeId, depth, cost } = heap.pop()!; + // Skip stale heap entries (cost beaten by a later push). + if (cost > (dist.get(nodeId) ?? Infinity)) continue; + if (depth >= maxDepth) continue; + + const neighbors = adjacency.get(nodeId) ?? []; + for (const { neighborId, edge } of neighbors) { + const nextNode = nodeIndex.get(neighborId); + if (!nextNode) continue; + // Clamp weight to avoid division-by-zero on malformed edges; + // 0.01 is below the documented 0.1 floor. + const edgeCost = 1 / Math.max(edge.weight, 0.01); + const newCost = cost + edgeCost; + if (newCost < (dist.get(neighborId) ?? Infinity)) { + dist.set(neighborId, newCost); + pathTo.set(neighborId, [ + ...pathTo.get(nodeId)!, + { node: nextNode, edge }, + ]); + heap.push({ nodeId: neighborId, depth: depth + 1, cost: newCost }); + } + } + } + + // Drop the startNode's own entry before returning: callers + // (searchByEntities, expandFromChunks) score start-node + // observations via a dedicated fallback loop with score=1.0. If + // we leave it in here, the start-path (length 1, no edges) goes + // through the generic path-scoring loop first — pathLength=1 + + // empty edgeWeights makes avgWeight fall to 0.5, the obs get + // marked visited, and the score=1.0 fallback becomes dead code. + pathTo.delete(startNode.id); + return Array.from(pathTo.values()); + } +} + +// Minimal binary min-heap. Pulled inline so graph-retrieval doesn't +// take a new dependency for the perf-critical inner loop of #328. +// Comparator returns negative when `a` should pop before `b`. +class MinHeap { + private heap: T[] = []; + + constructor(private compare: (a: T, b: T) => number) {} + + size(): number { + return this.heap.length; + } + + push(value: T): void { + this.heap.push(value); + this.bubbleUp(this.heap.length - 1); + } + + pop(): T | undefined { + if (this.heap.length === 0) return undefined; + const top = this.heap[0]; + const last = this.heap.pop()!; + if (this.heap.length > 0) { + this.heap[0] = last; + this.sinkDown(0); + } + return top; + } + + private bubbleUp(i: number): void { + while (i > 0) { + const parent = (i - 1) >> 1; + if (this.compare(this.heap[i], this.heap[parent]) < 0) { + [this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]]; + i = parent; + } else break; + } + } + + private sinkDown(i: number): void { + const n = this.heap.length; + while (true) { + const left = 2 * i + 1; + const right = 2 * i + 2; + let smallest = i; + if (left < n && this.compare(this.heap[left], this.heap[smallest]) < 0) { + smallest = left; + } + if (right < n && this.compare(this.heap[right], this.heap[smallest]) < 0) { + smallest = right; + } + if (smallest === i) break; + [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]]; + i = smallest; + } + } +} diff --git a/src/functions/graph.ts b/src/functions/graph.ts new file mode 100644 index 0000000..bb0d727 --- /dev/null +++ b/src/functions/graph.ts @@ -0,0 +1,997 @@ +import type { ISdk } from "iii-sdk"; +import type { + GraphNode, + GraphEdge, + GraphQueryResult, + GraphSnapshot, + CompressedObservation, + MemoryProvider, +} from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { + GRAPH_EXTRACTION_SYSTEM, + buildGraphExtractionPrompt, +} from "../prompts/graph-extraction.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +// #753: keep the response payload below the iii state channel ceiling. +// 500 nodes + their incident edges hold well under the limit on the +// reported 11k-node / 28k-edge corpus, and 5,000 is the upper bound a +// caller can request explicitly. Tuned conservatively because edges +// fan out faster than nodes. +const DEFAULT_GRAPH_QUERY_LIMIT = 500; +const MAX_GRAPH_QUERY_LIMIT = 5000; + +// #814: the precomputed snapshot covers the top-degree subgraph used by +// the empty-body / nodeType-only branch — the path the viewer hits on +// tab load. Sized to match the default query limit so the snapshot can +// service a default-cap request without falling back to live +// enumeration. Aggregate stats (nodesByType / edgesByType) are computed +// fresh during rebuild and stored alongside. +const SNAPSHOT_TOP_NODES = DEFAULT_GRAPH_QUERY_LIMIT; +const SNAPSHOT_KEY = "current"; + +// `state::list` over a 75K-node scope can exceed the iii invocation +// timeout. The query handler races the enumeration against this budget +// and falls back to the snapshot (or a warning envelope) when the live +// path is too slow. 6000ms leaves headroom under the default 8s engine +// invocation deadline. +const LIVE_ENUMERATION_BUDGET_MS = 6000; + +function withTimeout(p: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout( + () => reject(new Error(`${label}: exceeded ${ms}ms budget`)), + ms, + ); + p.then( + (v) => { + clearTimeout(t); + resolve(v); + }, + (err) => { + clearTimeout(t); + reject(err); + }, + ); + }); +} + +function emptySnapshot(): GraphSnapshot { + return { + version: 1, + topNodes: [], + topEdges: [], + topDegrees: {}, + stats: { + totalNodes: 0, + totalEdges: 0, + nodesByType: {}, + edgesByType: {}, + }, + updatedAt: new Date(0).toISOString(), + dirty: true, + }; +} + +async function readSnapshot(kv: StateKV): Promise { + try { + const snap = await kv.get(KV.graphSnapshot, SNAPSHOT_KEY); + if (snap && typeof snap === "object" && snap.version === 1) { + return snap; + } + return null; + } catch (err) { + logger.warn("Graph snapshot read failed", { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +function buildSnapshotFromArrays( + nodes: GraphNode[], + edges: GraphEdge[], +): GraphSnapshot { + const liveNodes = nodes.filter((n) => !n.stale); + const liveEdges = edges.filter((e) => !e.stale); + // Build the global degree map once so we can both rank by it AND + // snapshot the per-top-node values into topDegrees for synchronous + // re-sort after incremental edge writes. + const degree = new Map(); + for (const e of liveEdges) { + degree.set(e.sourceNodeId, (degree.get(e.sourceNodeId) ?? 0) + 1); + degree.set(e.targetNodeId, (degree.get(e.targetNodeId) ?? 0) + 1); + } + const ranked = [...liveNodes] + .sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0)) + .slice(0, SNAPSHOT_TOP_NODES); + const rankedIds = new Set(ranked.map((n) => n.id)); + const topEdges = liveEdges.filter( + (e) => rankedIds.has(e.sourceNodeId) && rankedIds.has(e.targetNodeId), + ); + const topDegrees: Record = {}; + for (const n of ranked) { + topDegrees[n.id] = degree.get(n.id) ?? 0; + } + const nodesByType: Record = {}; + for (const n of liveNodes) { + nodesByType[n.type] = (nodesByType[n.type] || 0) + 1; + } + const edgesByType: Record = {}; + for (const e of liveEdges) { + edgesByType[e.type] = (edgesByType[e.type] || 0) + 1; + } + return { + version: 1, + topNodes: ranked, + topEdges, + topDegrees, + stats: { + totalNodes: liveNodes.length, + totalEdges: liveEdges.length, + nodesByType, + edgesByType, + }, + updatedAt: new Date().toISOString(), + dirty: false, + }; +} + +function paginateFromSnapshot( + snap: GraphSnapshot, + filterType: string | undefined, + limit: number, + offset: number, +): GraphQueryResult { + const filteredNodes = filterType + ? snap.topNodes.filter((n) => n.type === filterType) + : snap.topNodes; + const total = filterType + ? snap.stats.nodesByType[filterType] ?? 0 + : snap.stats.totalNodes; + const pageNodes = filteredNodes.slice(offset, offset + limit); + const pageIds = new Set(pageNodes.map((n) => n.id)); + const pageEdges = snap.topEdges.filter( + (e) => pageIds.has(e.sourceNodeId) && pageIds.has(e.targetNodeId), + ); + return { + nodes: pageNodes, + edges: pageEdges, + depth: 0, + totalNodes: total, + totalEdges: snap.stats.totalEdges, + truncated: total > pageNodes.length, + limit, + offset, + fromSnapshot: true, + }; +} + +// #814 v2: the rebuild path won't terminate on corpora large enough +// that kv.list returns a payload too big to JSON.parse without +// starving the iii heartbeat. We don't actually know the corpus size +// without enumerating, but we can refuse to start a rebuild if the +// snapshot's recorded `totalNodes` already exceeds this threshold — +// the rebuild path is unreliable above it, and an incremental +// extract-driven snapshot is the right approach for those corpora. +// Operators above the threshold should use mem::graph-reset and let +// future extracts rebuild incrementally. +const REBUILD_SAFE_NODE_CEILING = 25000; + +function nameIndexKey(type: string, name: string): string { + return `${type}|${name}`; +} + +function edgeIndexKey( + sourceNodeId: string, + targetNodeId: string, + type: string, +): string { + return `${sourceNodeId}|${targetNodeId}|${type}`; +} + +// Mutates `snap` to apply a +1 (or -1) degree delta for nodeId, +// maintaining the top-N ranking. Returns the new degree. Reads / +// writes the per-node degree counter via targeted kv.get/set so we +// never enumerate. Top-N membership flips when: +// - node's new degree > current min in topNodes AND it's not in +// topNodes (promote, evict tail if topNodes is full) +// - node IS in topNodes and its position needs resorting (re-sort +// topNodes in place) +async function applyDegreeDelta( + kv: StateKV, + snap: GraphSnapshot, + nodeId: string, + delta: number, +): Promise { + const prev = (await kv.get(KV.graphNodeDegree, nodeId)) ?? 0; + const next = Math.max(0, prev + delta); + await kv.set(KV.graphNodeDegree, nodeId, next); + + const inTop = snap.topNodes.findIndex((n) => n.id === nodeId); + if (inTop !== -1) { + // Cache the new degree in topDegrees so the comparator runs + // synchronously over numbers, not async kv.get calls. Re-sort + // descending by degree. + snap.topDegrees[nodeId] = next; + snap.topNodes.sort( + (a, b) => + (snap.topDegrees[b.id] ?? 0) - (snap.topDegrees[a.id] ?? 0), + ); + return next; + } + + if (snap.topNodes.length < SNAPSHOT_TOP_NODES) { + // Capacity available — fetch + promote. + const node = await kv.get(KV.graphNodes, nodeId); + if (node && !node.stale) { + snap.topNodes.push(node); + snap.topDegrees[node.id] = next; + snap.topNodes.sort( + (a, b) => + (snap.topDegrees[b.id] ?? 0) - (snap.topDegrees[a.id] ?? 0), + ); + } + return next; + } + + // topNodes is full; the cutoff is the tail's cached degree. + const tailEntry = snap.topNodes[snap.topNodes.length - 1]; + if (!tailEntry) return next; + const tailDegree = snap.topDegrees[tailEntry.id] ?? 0; + if (next > tailDegree) { + const node = await kv.get(KV.graphNodes, nodeId); + if (node && !node.stale) { + const evicted = snap.topNodes.pop(); + if (evicted) delete snap.topDegrees[evicted.id]; + snap.topNodes.push(node); + snap.topDegrees[node.id] = next; + snap.topNodes.sort( + (a, b) => + (snap.topDegrees[b.id] ?? 0) - (snap.topDegrees[a.id] ?? 0), + ); + } + } + return next; +} + +function snapshotPushEdgeIfBothInTop( + snap: GraphSnapshot, + edge: GraphEdge, +): void { + const topIds = new Set(snap.topNodes.map((n) => n.id)); + if (topIds.has(edge.sourceNodeId) && topIds.has(edge.targetNodeId)) { + // Dedupe in case the same edge gets pushed twice. + if (!snap.topEdges.find((e) => e.id === edge.id)) { + snap.topEdges.push(edge); + } + } +} + +function mergeNode( + existing: GraphNode, + incoming: GraphNode, + obsIds: string[], + capturedAt: string, +): GraphNode { + return { + ...existing, + sourceObservationIds: [ + ...new Set([ + ...existing.sourceObservationIds, + ...incoming.sourceObservationIds, + ...obsIds, + ]), + ], + properties: { ...existing.properties, ...incoming.properties }, + updatedAt: capturedAt, + }; +} + +function mergeEdge( + existing: GraphEdge, + obsIds: string[], +): GraphEdge { + return { + ...existing, + sourceObservationIds: [ + ...new Set([...existing.sourceObservationIds, ...obsIds]), + ], + }; +} + +function resolvePagination( + rawLimit: number | undefined, + rawOffset: number | undefined, +): { limit: number; offset: number } { + const requested = typeof rawLimit === "number" && Number.isFinite(rawLimit) + ? Math.floor(rawLimit) + : DEFAULT_GRAPH_QUERY_LIMIT; + const limit = Math.max(1, Math.min(requested, MAX_GRAPH_QUERY_LIMIT)); + const offset = Math.max( + 0, + typeof rawOffset === "number" && Number.isFinite(rawOffset) + ? Math.floor(rawOffset) + : 0, + ); + return { limit, offset }; +} + +function paginate( + nodes: GraphNode[], + allEdges: GraphEdge[], + depth: number, + limit: number, + offset: number, +): GraphQueryResult { + const totalNodes = nodes.length; + const pageNodes = nodes.slice(offset, offset + limit); + const pageNodeIds = new Set(pageNodes.map((n) => n.id)); + // Edges restricted to the page so the response payload scales with + // `limit`, not with the global edge count. An edge is included only + // when BOTH endpoints land in the page — half-edges to nodes outside + // the page would render as dangling links in the viewer. + const pageEdges = allEdges.filter( + (e) => pageNodeIds.has(e.sourceNodeId) && pageNodeIds.has(e.targetNodeId), + ); + // Total edges (for the same node universe). Counted unbounded so the + // viewer can show "showing X of Y" without re-querying. + const universeIds = new Set(nodes.map((n) => n.id)); + const totalEdges = allEdges.reduce( + (count, e) => + universeIds.has(e.sourceNodeId) && universeIds.has(e.targetNodeId) + ? count + 1 + : count, + 0, + ); + return { + nodes: pageNodes, + edges: pageEdges, + depth, + totalNodes, + totalEdges, + truncated: totalNodes > pageNodes.length, + limit, + offset, + }; +} + +// Parse all key="value" pairs from a tag's attribute string, in any +// order. The previous parser hard-coded attribute order +// (type before name on , type/source/target/weight on +// ) and silently dropped nodes/edges when the upstream +// LLM emitted attributes in a different order — Codex in particular +// likes to lead with `name=` (#635). +function parseAttrs(raw: string): Record { + const attrs: Record = {}; + const attrRegex = /([A-Za-z_][\w:-]*)="([^"]*)"/g; + let m; + while ((m = attrRegex.exec(raw)) !== null) { + attrs[m[1]] = m[2]; + } + return attrs; +} + +function parseGraphXml( + xml: string, + observationIds: string[], +): { + nodes: GraphNode[]; + edges: GraphEdge[]; +} { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const now = new Date().toISOString(); + + // Two passes because can be self-closing or have a body + // ( children). The self-closing form needs `[^>]*[^/]` on + // the attr group so the trailing `/` isn't swallowed into the match + // (root cause of #494). The explicit-close form picks up the + // property block. + const entitySelfClose = /]*?)\/>/g; + const entityWithBody = /]*[^/])>([\s\S]*?)<\/entity>/g; + + const addEntity = (rawAttrs: string, propsBlock = ""): void => { + const attrs = parseAttrs(rawAttrs); + const type = attrs["type"] as GraphNode["type"] | undefined; + const name = attrs["name"]; + if (!type || !name) return; + const properties: Record = {}; + const propRegex = /([^<]*)<\/property>/g; + let propMatch; + while ((propMatch = propRegex.exec(propsBlock)) !== null) { + properties[propMatch[1]] = propMatch[2]; + } + nodes.push({ + id: generateId("gn"), + type, + name, + properties, + sourceObservationIds: observationIds, + createdAt: now, + }); + }; + + let match; + while ((match = entitySelfClose.exec(xml)) !== null) { + addEntity(match[1]); + } + while ((match = entityWithBody.exec(xml)) !== null) { + addEntity(match[1], match[2]); + } + + const relRegex = /]*?)\/>/g; + while ((match = relRegex.exec(xml)) !== null) { + const attrs = parseAttrs(match[1]); + const type = attrs["type"] as GraphEdge["type"] | undefined; + const sourceName = attrs["source"]; + const targetName = attrs["target"]; + if (!type || !sourceName || !targetName) continue; + const parsedWeight = parseFloat(attrs["weight"] ?? ""); + const weight = Number.isFinite(parsedWeight) ? parsedWeight : 0.5; + + const sourceNode = nodes.find((n) => n.name === sourceName); + const targetNode = nodes.find((n) => n.name === targetName); + if (!sourceNode || !targetNode) continue; + edges.push({ + id: generateId("ge"), + type, + sourceNodeId: sourceNode.id, + targetNodeId: targetNode.id, + weight: Math.max(0, Math.min(1, weight)), + sourceObservationIds: observationIds, + createdAt: now, + }); + } + + return { nodes, edges }; +} + +export function registerGraphFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + 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, + })), + ); + + try { + const response = await provider.compress( + GRAPH_EXTRACTION_SYSTEM, + prompt, + ); + + const obsIds = data.observations.map((o) => o.id); + const { nodes, edges } = parseGraphXml(response, obsIds); + + // #814 v2: targeted name-index lookups replace the O(n) scan + // over `kv.list(KV.graphNodes)`. At 75K nodes the + // list payload exceeds the iii heartbeat budget and the worker + // dies before merge can complete. Each name-index entry is a + // single small kv.get/set pair. + const snap = (await readSnapshot(kv)) ?? emptySnapshot(); + const capturedAt = new Date().toISOString(); + let newNodeCount = 0; + let newEdgeCount = 0; + const newEdgesForTopCheck: GraphEdge[] = []; + + for (const node of nodes) { + const indexKey = nameIndexKey(node.type, node.name); + const existingId = await kv.get( + KV.graphNameIndex, + indexKey, + ); + + let existing: GraphNode | null = null; + if (existingId) { + existing = await kv.get(KV.graphNodes, existingId); + // #825 follow-up: name-index lookups can resolve into + // pre-reset rows. Drop them so extract writes a fresh + // node + index entry instead of silently reconnecting + // to a legacy orphan (which would keep the snapshot at + // 0 forever after a reset). + if ( + existing && + snap.resetAt && + typeof existing.createdAt === "string" && + existing.createdAt < snap.resetAt + ) { + existing = null; + } + } + + if (existing) { + const merged = mergeNode(existing, node, obsIds, capturedAt); + await kv.set(KV.graphNodes, existing.id, merged); + // Update topNodes entry if present so a stale clone isn't + // returned from the snapshot fast path. + const topIdx = snap.topNodes.findIndex( + (n) => n.id === existing!.id, + ); + if (topIdx !== -1) snap.topNodes[topIdx] = merged; + } else { + await kv.set(KV.graphNodes, node.id, node); + await kv.set(KV.graphNameIndex, indexKey, node.id); + await kv.set(KV.graphNodeDegree, node.id, 0); + snap.stats.totalNodes += 1; + snap.stats.nodesByType[node.type] = + (snap.stats.nodesByType[node.type] ?? 0) + 1; + newNodeCount += 1; + if (snap.topNodes.length < SNAPSHOT_TOP_NODES) { + // Degree 0 still beats an empty slot — sit at the tail + // until edges arrive and promote. + snap.topNodes.push(node); + snap.topDegrees[node.id] = 0; + } + } + } + + for (const edge of edges) { + const eKey = edgeIndexKey( + edge.sourceNodeId, + edge.targetNodeId, + edge.type, + ); + const existingId = await kv.get(KV.graphEdgeKey, eKey); + + let existing: GraphEdge | null = null; + if (existingId) { + existing = await kv.get(KV.graphEdges, existingId); + // Same #825 orphan check as the node path above. + if ( + existing && + snap.resetAt && + typeof existing.createdAt === "string" && + existing.createdAt < snap.resetAt + ) { + existing = null; + } + } + + if (existing) { + const merged = mergeEdge(existing, obsIds); + await kv.set(KV.graphEdges, existing.id, merged); + // Replace cached topEdges entry too if present. + const topIdx = snap.topEdges.findIndex( + (e) => e.id === existing!.id, + ); + if (topIdx !== -1) snap.topEdges[topIdx] = merged; + } else { + await kv.set(KV.graphEdges, edge.id, edge); + await kv.set(KV.graphEdgeKey, eKey, edge.id); + snap.stats.totalEdges += 1; + snap.stats.edgesByType[edge.type] = + (snap.stats.edgesByType[edge.type] ?? 0) + 1; + newEdgeCount += 1; + await applyDegreeDelta(kv, snap, edge.sourceNodeId, +1); + await applyDegreeDelta(kv, snap, edge.targetNodeId, +1); + newEdgesForTopCheck.push(edge); + } + } + + // Push newly-added edges into snapshot.topEdges if both + // endpoints are in the top-N (post-degree-delta). Done after + // all degree updates so the topIds set is stable. + for (const edge of newEdgesForTopCheck) { + snapshotPushEdgeIfBothInTop(snap, edge); + } + + if (newNodeCount > 0 || newEdgeCount > 0) { + snap.updatedAt = capturedAt; + snap.dirty = false; + await kv.set(KV.graphSnapshot, SNAPSHOT_KEY, snap); + } + + await recordAudit(kv, "observe", "mem::graph-extract", obsIds, { + nodesExtracted: nodes.length, + edgesExtracted: edges.length, + }); + + logger.info("Graph extraction complete", { + nodes: nodes.length, + edges: edges.length, + newNodes: newNodeCount, + newEdges: newEdgeCount, + }); + return { + success: true, + nodesAdded: nodes.length, + edgesAdded: edges.length, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Graph extraction failed", { error: msg }); + return { success: false, error: msg }; + } + }, + ); + + // #753: every branch now applies a default cap and reports the + // unbounded `total*` counts. Before this change, an unfiltered POST + // /graph/query body (`{}`) on a corpus with ~10k+ nodes serialized + // to a payload large enough that the iii state response channel + // rejected it with HTTP 500 "Invocation stopped", leaving the viewer + // graph tab silently blank. + sdk.registerFunction("mem::graph-query", + async (data: { + startNodeId?: string; + nodeType?: string; + maxDepth?: number; + query?: string; + limit?: number; + offset?: number; + }): Promise => { + const maxDepth = Math.min(data.maxDepth || 3, 5); + const { limit, offset } = resolvePagination(data.limit, data.offset); + + // #814 v2: the empty-body / nodeType-only path NEVER enumerates. + // It reads the snapshot exclusively. The snapshot is updated + // inline by graph-extract, so for newly-built corpora it's + // always current. For legacy corpora missing a snapshot the + // operator must run mem::graph-snapshot-rebuild (safe under + // REBUILD_SAFE_NODE_CEILING) or mem::graph-reset to wipe and + // rebuild incrementally from new observations. + const noWalk = !data.query && !data.startNodeId; + if (noWalk) { + const snap = await readSnapshot(kv); + if (snap && snap.stats.totalNodes > 0) { + return paginateFromSnapshot(snap, data.nodeType, limit, offset); + } + return { + nodes: [], + edges: [], + depth: 0, + totalNodes: 0, + totalEdges: 0, + truncated: false, + limit, + offset, + warning: + "No graph snapshot available. Either no graph has been " + + "extracted yet, or you are on a legacy corpus from a pre-#814 " + + "agentmemory build. Run POST /agentmemory/graph/snapshot-rebuild " + + "(safe up to ~25K nodes) or POST /agentmemory/graph/reset to " + + "wipe and let future extracts repopulate.", + }; + } + + // Query / startNodeId paths still need broader access. Race the + // live enumeration against a wall-clock budget so a long + // kv.list doesn't block the worker indefinitely. On timeout the + // caller gets a snapshot-backed approximation instead of a 500. + let allNodes: GraphNode[]; + let allEdges: GraphEdge[]; + try { + const [rawNodes, rawEdges] = await withTimeout( + Promise.all([ + kv.list(KV.graphNodes), + kv.list(KV.graphEdges), + ]), + LIVE_ENUMERATION_BUDGET_MS, + "graph-query enumeration", + ); + allNodes = rawNodes.filter((n) => !n.stale); + allEdges = rawEdges.filter((e) => !e.stale); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("Graph query enumeration timed out, using snapshot", { + error: msg, + }); + const snap = await readSnapshot(kv); + if (snap) { + return { + ...paginateFromSnapshot(snap, data.nodeType, limit, offset), + warning: + "Live graph enumeration exceeded budget. Query / " + + "startNodeId paths degrade on >25K-node corpora until a " + + "per-node edge index lands. Result reflects top-degree " + + "snapshot, not the requested walk.", + }; + } + return { + nodes: [], + edges: [], + depth: 0, + totalNodes: 0, + totalEdges: 0, + truncated: false, + limit, + offset, + warning: + "Graph enumeration exceeded budget and no snapshot is available.", + }; + } + + if (data.query) { + const lower = data.query.toLowerCase(); + const matchingNodes = allNodes.filter( + (n) => + n.name.toLowerCase().includes(lower) || + Object.values(n.properties).some( + (v) => typeof v === "string" && v.toLowerCase().includes(lower), + ), + ); + return paginate(matchingNodes, allEdges, 0, limit, offset); + } + + if (data.startNodeId) { + const visited = new Set(); + const visitedEdges = new Set(); + const resultNodes: GraphNode[] = []; + const resultEdges: GraphEdge[] = []; + const queue: Array<{ nodeId: string; depth: number }> = [ + { nodeId: data.startNodeId, depth: 0 }, + ]; + + while (queue.length > 0) { + const { nodeId, depth } = queue.shift()!; + if (visited.has(nodeId) || depth > maxDepth) continue; + visited.add(nodeId); + + const node = allNodes.find((n) => n.id === nodeId); + if (node) { + if (!data.nodeType || node.type === data.nodeType) { + resultNodes.push(node); + } + } + + const neighborEdges = allEdges.filter( + (e) => e.sourceNodeId === nodeId || e.targetNodeId === nodeId, + ); + for (const edge of neighborEdges) { + if (!visitedEdges.has(edge.id)) { + visitedEdges.add(edge.id); + resultEdges.push(edge); + } + const nextId = + edge.sourceNodeId === nodeId + ? edge.targetNodeId + : edge.sourceNodeId; + if (!visited.has(nextId)) { + queue.push({ nodeId: nextId, depth: depth + 1 }); + } + } + } + + return paginate(resultNodes, resultEdges, maxDepth, limit, offset); + } + + // Unreachable — noWalk branch handles the rest. + return paginate([], [], 0, limit, offset); + }, + ); + + // #814 v2: graph-stats reads the snapshot exclusively. The snapshot + // is maintained inline by mem::graph-extract, so for any corpus built + // on a post-#814 agentmemory the stats are always current without an + // enumeration. Legacy corpora without a snapshot get an empty + // envelope + a warning pointing at the snapshot-rebuild or graph-reset + // endpoints — never a 500. + sdk.registerFunction("mem::graph-stats", async () => { + const snap = await readSnapshot(kv); + if (snap) { + return { + ...snap.stats, + fromSnapshot: true, + updatedAt: snap.updatedAt, + ...(snap.dirty + ? { + warning: + "Snapshot is marked dirty (write was in-flight when read). " + + "Counts are eventually consistent.", + } + : {}), + }; + } + return { + totalNodes: 0, + totalEdges: 0, + nodesByType: {}, + edgesByType: {}, + fromSnapshot: false, + warning: + "No graph snapshot available. Run POST /agentmemory/graph/snapshot-rebuild " + + "(safe up to ~25K nodes) or POST /agentmemory/graph/reset to wipe " + + "and let future extracts repopulate.", + }; + }); + + // #814 v2: explicit rebuild backfills the snapshot AND the name / + // edge-key / degree indexes from existing graphNodes/graphEdges + // scopes. This is the path operators run once after upgrading to a + // post-#814 build to bring legacy corpora online. It enumerates via + // kv.list — the same pair that breaks at 75K+ — so we refuse to + // run on corpora large enough that the response payload would + // block the worker heartbeat. Above the ceiling the only safe path + // is mem::graph-reset followed by incremental re-extraction. + sdk.registerFunction( + "mem::graph-snapshot-rebuild", + async (data?: { force?: boolean }) => { + const started = Date.now(); + // #825: pre-flight refusal for legacy corpora. The old guard + // checked node count AFTER kv.list, but the heartbeat dies at + // ~0.35s on a 75K-node response — long before the wall-clock + // budget can fire. We can't safely enumerate to discover size. + // + // Heuristic: if no snapshot exists, the corpus is either empty + // or legacy. The empty case has nothing to rebuild; the legacy + // case will crash. Refuse both unless `force: true` is passed + // (operator opt-in to attempt rebuild on a corpus they know is + // small enough — typically under 10K nodes on the default iii + // state adapter). + // Strict boolean check on force — accept only literal `true`, + // never truthy strings/numbers, so a hand-crafted JSON payload + // can't accidentally bypass the legacy-corpus safeguard. + const forceRebuild = data?.force === true; + try { + const existing = await readSnapshot(kv); + if (!existing && !forceRebuild) { + logger.warn("Graph snapshot rebuild refused: no prior snapshot", { + hint: "legacy corpus or empty store", + }); + return { + success: false, + legacyCorpus: true, + error: + "No prior snapshot found. Rebuild would call kv.list on " + + "KV.graphNodes/Edges, which heartbeat-crashes the worker " + + "on corpora past the iii state response budget (~25K nodes). " + + "Either (a) call POST /agentmemory/graph/reset to drop into " + + "incremental-only mode and rebuild from new extracts, or " + + "(b) re-send with `force: true` if you're certain the " + + "corpus is small.", + }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("Graph snapshot pre-flight read failed", { error: msg }); + // Fall through; the user passed force=true or the snapshot + // read itself failed (separate problem). + } + + try { + const [nodes, edges] = await withTimeout( + Promise.all([ + kv.list(KV.graphNodes), + kv.list(KV.graphEdges), + ]), + LIVE_ENUMERATION_BUDGET_MS, + "graph-snapshot-rebuild enumeration", + ); + + if (nodes.length > REBUILD_SAFE_NODE_CEILING) { + logger.warn("Graph snapshot rebuild aborted: corpus too large", { + totalNodes: nodes.length, + ceiling: REBUILD_SAFE_NODE_CEILING, + }); + return { + success: false, + tooLarge: true, + totalNodes: nodes.length, + ceiling: REBUILD_SAFE_NODE_CEILING, + error: + `Corpus has ${nodes.length} graph nodes; safe-rebuild ceiling ` + + `is ${REBUILD_SAFE_NODE_CEILING}. Run POST /agentmemory/graph/reset ` + + `to wipe and let future extracts rebuild incrementally.`, + }; + } + + // Backfill the targeted-lookup indexes so post-rebuild + // graph-extract calls hit the O(1) path instead of falling + // through to the (already-removed) full-scope scan. Batch + // writes via Promise.all to avoid N sequential round-trips — + // BATCH_SIZE bounds in-flight writes so we don't open thousands + // of concurrent state channels on huge corpora. + const liveNodes = nodes.filter((n) => !n.stale); + const liveEdges = edges.filter((e) => !e.stale); + const degree = new Map(); + for (const e of liveEdges) { + degree.set(e.sourceNodeId, (degree.get(e.sourceNodeId) ?? 0) + 1); + degree.set(e.targetNodeId, (degree.get(e.targetNodeId) ?? 0) + 1); + } + const BATCH_SIZE = 100; + for (let i = 0; i < liveNodes.length; i += BATCH_SIZE) { + const batch = liveNodes.slice(i, i + BATCH_SIZE); + await Promise.all( + batch.flatMap((n) => [ + kv.set(KV.graphNameIndex, nameIndexKey(n.type, n.name), n.id), + kv.set(KV.graphNodeDegree, n.id, degree.get(n.id) ?? 0), + ]), + ); + } + for (let i = 0; i < liveEdges.length; i += BATCH_SIZE) { + const batch = liveEdges.slice(i, i + BATCH_SIZE); + await Promise.all( + batch.map((e) => + kv.set( + KV.graphEdgeKey, + edgeIndexKey(e.sourceNodeId, e.targetNodeId, e.type), + e.id, + ), + ), + ); + } + + const snap = buildSnapshotFromArrays(nodes, edges); + await kv.set(KV.graphSnapshot, SNAPSHOT_KEY, snap); + const tookMs = Date.now() - started; + logger.info("Graph snapshot rebuilt", { + totalNodes: snap.stats.totalNodes, + totalEdges: snap.stats.totalEdges, + topNodes: snap.topNodes.length, + topEdges: snap.topEdges.length, + tookMs, + }); + return { + success: true, + ...snap.stats, + topNodes: snap.topNodes.length, + topEdges: snap.topEdges.length, + updatedAt: snap.updatedAt, + tookMs, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Graph snapshot rebuild failed", { error: msg }); + return { success: false, error: msg }; + } + }); + + // #814 v2 + #825: clean-restart escape hatch for corpora of any + // size, including the legacy 75K+ case that crashes kv.list. + // + // Previous reset walked kv.list(...) which is the + // exact primitive that heartbeat-crashes the worker on the corpus + // this reset was meant to recover (Allan's repro, 0.35s death). + // + // The new design is enumeration-free: write an empty snapshot and + // return. The hot path (mem::graph-query empty-body, mem::graph-stats) + // reads ONLY the snapshot post-#816, so a fresh empty snapshot + // makes the graph behave as if it were empty for every read. + // + // Future extracts repopulate the snapshot + side-indexes + // incrementally (graph-extract is O(1) per node post-#816 — it does + // not consult the legacy rows). + // + // Trade-off: legacy rows in KV.graphNodes / KV.graphEdges remain on + // disk as unreferenced orphans. They consume disk but are never + // read by any post-#816 code path. Cleanup is deferred to a future + // chunked-vacuum job; #816's broken vacuum-via-list strategy is + // what we are leaving behind here. + sdk.registerFunction("mem::graph-reset", async () => { + const started = Date.now(); + // Stamp resetAt=now on the empty snapshot. Future + // mem::graph-extract calls compare each name-index lookup's + // existing node `createdAt` against this timestamp; anything + // older counts as an orphan and is dropped from the merge path, + // forcing extract to write a fresh row instead of reconnecting + // to a pre-reset entry. + const resetSnapshot: GraphSnapshot = { + ...emptySnapshot(), + resetAt: new Date().toISOString(), + }; + await kv.set(KV.graphSnapshot, SNAPSHOT_KEY, resetSnapshot); + const counts: Record = { + [KV.graphSnapshot]: 1, + }; + const tookMs = Date.now() - started; + logger.info("Graph state reset", { counts, tookMs }); + return { success: true, cleared: counts, tookMs }; + }); +} diff --git a/src/functions/image-quota-cleanup.ts b/src/functions/image-quota-cleanup.ts new file mode 100644 index 0000000..d07dfa9 --- /dev/null +++ b/src/functions/image-quota-cleanup.ts @@ -0,0 +1,99 @@ +import { TriggerAction, type ISdk } from "iii-sdk"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { IMAGES_DIR, getMaxBytes, deleteImage } from "../utils/image-store.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { logger } from "../logger.js"; + +const GRACE_PERIOD_MS = 30_000; + +export function registerImageQuotaCleanup(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction( + "mem::image-quota-cleanup", + async () => { + const now = Date.now(); + + return withKeyedLock("system:cleanupLock", async () => { + let totalSize = 0; + const fileStats: Array<{ filePath: string; size: number; mtimeMs: number }> = []; + + try { + const files = await readdir(IMAGES_DIR); + for (const file of files) { + if (file.startsWith(".")) continue; + const filePath = join(IMAGES_DIR, file); + const s = await stat(filePath); + if (s.isFile()) { + fileStats.push({ filePath, size: s.size, mtimeMs: s.mtimeMs }); + totalSize += s.size; + } + } + } catch { + return { success: true, evicted: 0, freedBytes: 0 }; + } + + const limit = getMaxBytes(); + if (totalSize <= limit) { + return { success: true, evicted: 0, freedBytes: 0, underQuota: true }; + } + + fileStats.sort((a, b) => a.mtimeMs - b.mtimeMs); + + let totalToFree = totalSize - limit; + let evicted = 0; + let freedBytes = 0; + + for (const f of fileStats) { + if (totalToFree <= 0) break; + + if (now - f.mtimeMs < GRACE_PERIOD_MS) { + continue; + } + + await withKeyedLock(`imgRef:${f.filePath}`, async () => { + let refCount: number; + try { + const { getImageRefCount } = await import("./image-refs.js"); + refCount = await getImageRefCount(kv, f.filePath); + } catch (err) { + // Fail-closed: if we cannot determine refCount we must NOT + // delete the image. Previously we let refCount fall through + // to the default 0 and evicted, which risks deleting + // still-referenced images on transient KV errors. + logger.error("Failed to read refCount; skipping eviction", { + filePath: f.filePath, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + if (refCount > 0) { + return; + } + + const { deletedBytes } = await deleteImage(f.filePath); + if (deletedBytes > 0) { + sdk.trigger({ + function_id: "mem::disk-size-delta", + payload: { deltaBytes: -deletedBytes }, + action: TriggerAction.Void(), + }); + totalToFree -= deletedBytes; + freedBytes += deletedBytes; + evicted++; + } + }); + } + + if (evicted > 0) { + const freedMb = (freedBytes / (1024 * 1024)).toFixed(1); + logger.info("Image quota cleanup complete", { evicted, freedMb }); + } + + return { success: true, evicted, freedBytes }; + }); + }, + ); +} diff --git a/src/functions/image-refs.ts b/src/functions/image-refs.ts new file mode 100644 index 0000000..ce1661d --- /dev/null +++ b/src/functions/image-refs.ts @@ -0,0 +1,38 @@ +import { TriggerAction, type ISdk } from "iii-sdk"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { deleteImage, touchImage } from "../utils/image-store.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; + +export async function getImageRefCount(kv: StateKV, filePath: string): Promise { + const count = await kv.get(KV.imageRefs, filePath); + return count ? Number(count) : 0; +} + +export async function incrementImageRef(kv: StateKV, filePath: string): Promise { + return withKeyedLock(`imgRef:${filePath}`, async () => { + const current = await getImageRefCount(kv, filePath); + await kv.set(KV.imageRefs, filePath, current + 1); + await touchImage(filePath); + }); +} + +export async function decrementImageRef(kv: StateKV, sdk: ISdk, filePath: string): Promise { + return withKeyedLock(`imgRef:${filePath}`, async () => { + const current = await getImageRefCount(kv, filePath); + if (current <= 1) { + await kv.delete(KV.imageEmbeddings, filePath); + await kv.delete(KV.imageRefs, filePath); + const { deletedBytes } = await deleteImage(filePath); + if (deletedBytes > 0) { + sdk.trigger({ + function_id: "mem::disk-size-delta", + payload: { deltaBytes: -deletedBytes }, + action: TriggerAction.Void(), + }); + } + } else { + await kv.set(KV.imageRefs, filePath, current - 1); + } + }); +} diff --git a/src/functions/leases.ts b/src/functions/leases.ts new file mode 100644 index 0000000..1a2de33 --- /dev/null +++ b/src/functions/leases.ts @@ -0,0 +1,251 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import type { Action, Lease } from "../types.js"; +import { recordAudit } from "./audit.js"; + +const DEFAULT_LEASE_TTL_MS = 10 * 60 * 1000; +const MAX_LEASE_TTL_MS = 60 * 60 * 1000; + +export function registerLeasesFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::lease-acquire", + async (data: { actionId: string; agentId: string; ttlMs?: number }) => { + if (!data.actionId || !data.agentId) { + return { success: false, error: "actionId and agentId are required" }; + } + + const rawTtl = typeof data.ttlMs === "number" && Number.isFinite(data.ttlMs) && data.ttlMs > 0 + ? data.ttlMs + : DEFAULT_LEASE_TTL_MS; + const ttl = Math.min(rawTtl, MAX_LEASE_TTL_MS); + + return withKeyedLock(`mem:action:${data.actionId}`, async () => { + const action = await kv.get(KV.actions, data.actionId); + if (!action) { + return { success: false, error: "action not found" }; + } + if (action.status === "done" || action.status === "cancelled") { + return { success: false, error: "action already completed" }; + } + if (action.status === "blocked") { + return { success: false, error: "action is blocked" }; + } + + const existingLeases = await kv.list(KV.leases); + const activeLease = existingLeases.find( + (l) => + l.actionId === data.actionId && + l.status === "active" && + new Date(l.expiresAt).getTime() > Date.now(), + ); + + if (activeLease) { + if (activeLease.agentId === data.agentId) { + return { + success: true, + lease: activeLease, + renewed: false, + message: "Already holding this lease", + }; + } + return { + success: false, + error: "action already leased", + heldBy: activeLease.agentId, + expiresAt: activeLease.expiresAt, + }; + } + + const now = new Date(); + const lease: Lease = { + id: generateId("lse"), + actionId: data.actionId, + agentId: data.agentId, + acquiredAt: now.toISOString(), + expiresAt: new Date(now.getTime() + ttl).toISOString(), + status: "active", + }; + + await kv.set(KV.leases, lease.id, lease); + await recordAudit(kv, "lease_acquire", "mem::lease-acquire", [lease.id], { + actionId: data.actionId, + agentId: data.agentId, + expiresAt: lease.expiresAt, + }); + + const before = { ...action }; + action.status = "active"; + action.assignedTo = data.agentId; + action.updatedAt = now.toISOString(); + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "action_update", "mem::lease-acquire", [action.id], { + before, + after: action, + }); + + return { success: true, lease, renewed: false }; + }); + }, + ); + + sdk.registerFunction("mem::lease-release", + async (data: { actionId: string; agentId: string; result?: string }) => { + if (!data.actionId || !data.agentId) { + return { success: false, error: "actionId and agentId are required" }; + } + + return withKeyedLock(`mem:action:${data.actionId}`, async () => { + const leases = await kv.list(KV.leases); + const activeLease = leases.find( + (l) => + l.actionId === data.actionId && + l.agentId === data.agentId && + l.status === "active" && + new Date(l.expiresAt).getTime() > Date.now(), + ); + + if (!activeLease) { + return { success: false, error: "no active lease found for this agent" }; + } + + activeLease.status = "released"; + await kv.set(KV.leases, activeLease.id, activeLease); + await recordAudit(kv, "lease_release", "mem::lease-release", [activeLease.id], { + actionId: data.actionId, + agentId: data.agentId, + status: "released", + }); + + const action = await kv.get(KV.actions, data.actionId); + if (action && action.status === "active" && action.assignedTo === data.agentId) { + const before = { ...action }; + if (data.result) { + action.status = "done"; + action.result = data.result; + } else { + action.status = "pending"; + } + action.assignedTo = undefined; + action.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "action_update", "mem::lease-release", [action.id], { + before, + after: action, + agentId: data.agentId, + }); + } + + return { success: true, released: true }; + }); + }, + ); + + sdk.registerFunction("mem::lease-renew", + async (data: { actionId: string; agentId: string; ttlMs?: number }) => { + if (!data.actionId || !data.agentId) { + return { success: false, error: "actionId and agentId are required" }; + } + + const rawTtl = typeof data.ttlMs === "number" && Number.isFinite(data.ttlMs) && data.ttlMs > 0 + ? data.ttlMs + : DEFAULT_LEASE_TTL_MS; + const ttl = Math.min(rawTtl, MAX_LEASE_TTL_MS); + + return withKeyedLock(`mem:action:${data.actionId}`, async () => { + const leases = await kv.list(KV.leases); + const activeLease = leases.find( + (l) => + l.actionId === data.actionId && + l.agentId === data.agentId && + l.status === "active" && + new Date(l.expiresAt).getTime() > Date.now(), + ); + + if (!activeLease) { + return { success: false, error: "no active (non-expired) lease to renew" }; + } + + const now = new Date(); + const base = Math.max(now.getTime(), new Date(activeLease.expiresAt).getTime()); + const beforeLease = { ...activeLease }; + activeLease.expiresAt = new Date(base + ttl).toISOString(); + activeLease.renewedAt = now.toISOString(); + await kv.set(KV.leases, activeLease.id, activeLease); + await recordAudit(kv, "lease_renew", "mem::lease-renew", [activeLease.id], { + actionId: data.actionId, + agentId: data.agentId, + before: beforeLease, + after: activeLease, + }); + + return { success: true, lease: activeLease }; + }); + }, + ); + + sdk.registerFunction("mem::lease-cleanup", + async () => { + const leases = await kv.list(KV.leases); + const now = Date.now(); + let expired = 0; + + for (const lease of leases) { + if ( + lease.status === "active" && + new Date(lease.expiresAt).getTime() <= now + ) { + const didExpire = await withKeyedLock( + `mem:action:${lease.actionId}`, + async () => { + const currentLease = await kv.get(KV.leases, lease.id); + if ( + !currentLease || + currentLease.status !== "active" || + new Date(currentLease.expiresAt).getTime() > Date.now() + ) { + return false; + } + currentLease.status = "expired"; + await kv.set(KV.leases, currentLease.id, currentLease); + await recordAudit(kv, "lease_release", "mem::lease-cleanup", [currentLease.id], { + action: "expire", + actionId: currentLease.actionId, + agentId: currentLease.agentId, + }); + + const action = await kv.get(KV.actions, currentLease.actionId); + const otherActiveLease = (await kv.list(KV.leases)).some( + (l) => + l.id !== currentLease.id && + l.actionId === currentLease.actionId && + l.status === "active" && + new Date(l.expiresAt).getTime() > Date.now(), + ); + if ( + action && + !otherActiveLease && + action.status === "active" && + action.assignedTo === currentLease.agentId + ) { + action.status = "pending"; + action.assignedTo = undefined; + action.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "action_update", "mem::lease-cleanup", [action.id], { + action: "status-change", + newStatus: action.status, + actionId: action.id, + }); + } + return true; + }, + ); + if (didExpire) expired++; + } + } + + return { success: true, expired }; + }, + ); +} diff --git a/src/functions/lessons.ts b/src/functions/lessons.ts new file mode 100644 index 0000000..9e69f46 --- /dev/null +++ b/src/functions/lessons.ts @@ -0,0 +1,283 @@ +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 { recordAudit } from "./audit.js"; + +function reinforceLesson(lesson: Lesson): void { + const now = new Date().toISOString(); + lesson.reinforcements++; + lesson.confidence = Math.min( + 1.0, + lesson.confidence + 0.1 * (1 - lesson.confidence), + ); + lesson.lastReinforcedAt = now; + lesson.updatedAt = now; +} + +export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::lesson-save", + async (data: { + content: string; + context?: string; + confidence?: number; + project?: string; + tags?: string[]; + source?: "crystal" | "manual" | "consolidation"; + sourceIds?: string[]; + }) => { + if (!data.content?.trim()) { + return { success: false, error: "content is required" }; + } + + const fp = fingerprintId("lsn", data.content.trim().toLowerCase()); + const existing = await kv.get(KV.lessons, fp); + + if (existing && !existing.deleted) { + reinforceLesson(existing); + if (data.context && !existing.context) { + existing.context = data.context; + } + await kv.set(KV.lessons, existing.id, existing); + + try { + await recordAudit(kv, "lesson_strengthen", "mem::lesson-save", [ + existing.id, + ]); + } catch {} + + return { + success: true, + action: "strengthened", + lesson: existing, + }; + } + + const confidence = + typeof data.confidence === "number" && + data.confidence >= 0 && + data.confidence <= 1 + ? data.confidence + : 0.5; + + const now = new Date().toISOString(); + const lesson: Lesson = { + id: fp, + content: data.content.trim(), + context: data.context?.trim() || "", + confidence, + reinforcements: 0, + source: data.source || "manual", + sourceIds: data.sourceIds || [], + project: data.project, + tags: data.tags || [], + createdAt: now, + updatedAt: now, + decayRate: 0.05, + }; + + await kv.set(KV.lessons, lesson.id, lesson); + + try { + await recordAudit(kv, "lesson_save", "mem::lesson-save", [lesson.id]); + } catch {} + + return { success: true, action: "created", lesson }; + }, + ); + + sdk.registerFunction("mem::lesson-recall", + async (data: { + query: string; + project?: string; + minConfidence?: number; + limit?: number; + }) => { + if (!data.query?.trim()) { + 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 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); + + try { + await recordAudit(kv, "lesson_recall", "mem::lesson-recall", [], { + query: data.query, + resultCount: scored.length, + }); + } catch {} + + return { + success: true, + lessons: scored.slice(0, limit).map((s) => ({ + ...s.lesson, + score: Math.round(s.score * 1000) / 1000, + })), + }; + }, + ); + + sdk.registerFunction("mem::lesson-list", + async (data: { + project?: string; + source?: string; + minConfidence?: number; + limit?: number; + }) => { + const limit = data.limit ?? 50; + const minConfidence = data.minConfidence ?? 0; + 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); + } + if (data.source) { + lessons = lessons.filter((l) => l.source === data.source); + } + + lessons.sort((a, b) => b.confidence - a.confidence); + + return { success: true, lessons: lessons.slice(0, limit) }; + }, + ); + + sdk.registerFunction("mem::lesson-strengthen", + async (data: { lessonId: string }) => { + if (!data.lessonId) { + return { success: false, error: "lessonId is required" }; + } + + const lesson = await kv.get(KV.lessons, data.lessonId); + if (!lesson || lesson.deleted) { + return { success: false, error: "lesson not found" }; + } + + reinforceLesson(lesson); + + await kv.set(KV.lessons, lesson.id, lesson); + + try { + await recordAudit(kv, "lesson_strengthen", "mem::lesson-strengthen", [ + lesson.id, + ]); + } catch {} + + return { success: true, lesson }; + }, + ); + + sdk.registerFunction("mem::lesson-decay-sweep", + async () => { + const lessons = await kv.list(KV.lessons); + let decayed = 0; + let softDeleted = 0; + const now = Date.now(); + const timestamp = new Date().toISOString(); + const dirty: Lesson[] = []; + const auditEvents: Array<{ + id: string; + action: "decay" | "soft-delete"; + beforeConfidence: number; + afterConfidence: number; + beforeDeleted: boolean; + afterDeleted: boolean; + }> = []; + + for (const lesson of lessons) { + if (lesson.deleted) continue; + + const baseline = lesson.lastDecayedAt || lesson.lastReinforcedAt || lesson.createdAt; + const weeksSinceBaseline = + (now - new Date(baseline).getTime()) / (1000 * 60 * 60 * 24 * 7); + + if (weeksSinceBaseline < 1) continue; + + const decay = lesson.decayRate * weeksSinceBaseline; + const newConfidence = Math.max(0.05, lesson.confidence - decay); + + if (newConfidence !== lesson.confidence) { + const beforeConfidence = lesson.confidence; + const beforeDeleted = !!lesson.deleted; + lesson.confidence = Math.round(newConfidence * 1000) / 1000; + lesson.lastDecayedAt = timestamp; + lesson.updatedAt = timestamp; + + if (lesson.confidence <= 0.1 && lesson.reinforcements === 0) { + lesson.deleted = true; + softDeleted++; + } else { + decayed++; + } + + dirty.push(lesson); + auditEvents.push({ + id: lesson.id, + action: lesson.deleted ? "soft-delete" : "decay", + beforeConfidence, + afterConfidence: lesson.confidence, + beforeDeleted, + afterDeleted: !!lesson.deleted, + }); + } + } + + await Promise.all(dirty.map((l) => kv.set(KV.lessons, l.id, l))); + await Promise.all( + auditEvents.map((event) => + recordAudit(kv, "lesson_strengthen", "mem::lesson-decay-sweep", [event.id], { + action: event.action, + actor: "system", + reason: "decay-sweep", + before: { + confidence: event.beforeConfidence, + deleted: event.beforeDeleted, + }, + after: { + confidence: event.afterConfidence, + deleted: event.afterDeleted, + }, + }), + ), + ); + + return { success: true, decayed, softDeleted, total: lessons.length }; + }, + ); +} diff --git a/src/functions/mesh.ts b/src/functions/mesh.ts new file mode 100644 index 0000000..ad52e5e --- /dev/null +++ b/src/functions/mesh.ts @@ -0,0 +1,495 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { recordAudit } from "./audit.js"; +import type { + MeshPeer, + Memory, + Action, + SemanticMemory, + ProceduralMemory, + MemoryRelation, + GraphNode, + GraphEdge, +} from "../types.js"; +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +function isPrivateIP(ip: string): boolean { + if (ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0") return true; + if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true; + if (ip === "169.254.169.254") return true; + if (ip.startsWith("fe80:") || ip.startsWith("fc00:") || ip.startsWith("fd")) return true; + if (ip.startsWith("::ffff:")) { + const v4 = ip.slice(7); + return isPrivateIP(v4); + } + return false; +} + +async function isAllowedUrl(urlStr: string): Promise { + try { + const parsed = new URL(urlStr); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + if (parsed.username || parsed.password) return false; + const host = parsed.hostname.toLowerCase(); + + if (host === "localhost") return false; + if (isIP(host) && isPrivateIP(host)) return false; + + if (!isIP(host)) { + try { + const resolved = await lookup(host, { all: true }); + if (resolved.some((r) => isPrivateIP(r.address))) return false; + } catch { + // DNS resolution failed — allow the URL (the actual fetch will fail if unreachable) + } + } + + return true; + } catch { + return false; + } +} + +const DEFAULT_SHARED_SCOPES = [ + "memories", + "actions", + "semantic", + "procedural", + "relations", + "graph:nodes", + "graph:edges", +]; + +interface MeshSyncPayload { + memories?: Memory[]; + actions?: Action[]; + semantic?: SemanticMemory[]; + procedural?: ProceduralMemory[]; + relations?: MemoryRelation[]; + graphNodes?: GraphNode[]; + graphEdges?: GraphEdge[]; +} + +async function lwwMergeList( + kv: StateKV, + scope: string, + items: T[] | undefined, + lockPrefix: string, + tsField: "updatedAt" | "createdAt", +): Promise { + if (!items || !Array.isArray(items)) return 0; + let count = 0; + for (const item of items) { + if (!item.id || typeof item.id !== "string") continue; + const ts = (item as Record)[tsField]; + if (typeof ts !== "string" || Number.isNaN(new Date(ts).getTime())) continue; + const wrote = await withKeyedLock(`${lockPrefix}:${item.id}`, async () => { + const existing = await kv.get(scope, item.id); + if (!existing) { + await kv.set(scope, item.id, item); + return true; + } + const existingTs = (existing as Record)[tsField] as string; + if (new Date(ts) > new Date(existingTs)) { + await kv.set(scope, item.id, item); + return true; + } + return false; + }); + if (wrote) count++; + } + return count; +} + +function graphNodeTs(node: GraphNode): string { + return node.updatedAt || node.createdAt; +} + +async function lwwMergeGraphNodes( + kv: StateKV, + items: GraphNode[] | undefined, +): Promise { + if (!items || !Array.isArray(items)) return 0; + let count = 0; + for (const item of items) { + if (!item.id || typeof item.id !== "string") continue; + const ts = graphNodeTs(item); + if (!ts || Number.isNaN(new Date(ts).getTime())) continue; + const wrote = await withKeyedLock(`mem:gnode:${item.id}`, async () => { + const existing = await kv.get(KV.graphNodes, item.id); + if (!existing) { + await kv.set(KV.graphNodes, item.id, item); + return true; + } + if (new Date(ts) > new Date(graphNodeTs(existing))) { + await kv.set(KV.graphNodes, item.id, item); + return true; + } + return false; + }); + if (wrote) count++; + } + return count; +} + +export function registerMeshFunction( + sdk: ISdk, + kv: StateKV, + meshAuthToken?: string, +): void { + sdk.registerFunction("mem::mesh-register", + async (data: { + url: string; + name: string; + sharedScopes?: string[]; + syncFilter?: { project?: string }; + }) => { + if (!data || typeof data !== "object") { + return { success: false, error: "payload required" }; + } + if (!data.url || !data.name) { + return { success: false, error: "url and name are required" }; + } + + if (!(await isAllowedUrl(data.url))) { + return { success: false, error: "URL blocked: private/local address not allowed" }; + } + + const existing = await kv.list(KV.mesh); + const duplicate = existing.find((p) => p.url === data.url); + if (duplicate) { + return { success: false, error: "peer already registered", peerId: duplicate.id }; + } + + const peer: MeshPeer = { + id: generateId("peer"), + url: data.url, + name: data.name, + status: "disconnected", + sharedScopes: data.sharedScopes || DEFAULT_SHARED_SCOPES, + syncFilter: data.syncFilter, + }; + + await kv.set(KV.mesh, peer.id, peer); + await recordAudit(kv, "mesh_sync", "mem::mesh-register", [peer.id], { + action: "mesh.register", + peerId: peer.id, + name: peer.name, + url: peer.url, + sharedScopes: peer.sharedScopes, + }); + return { success: true, peer }; + }, + ); + + sdk.registerFunction("mem::mesh-list", + async () => { + const peers = await kv.list(KV.mesh); + return { success: true, peers }; + }, + ); + + sdk.registerFunction("mem::mesh-sync", + async (data: { peerId?: string; scopes?: string[]; direction?: "push" | "pull" | "both" }) => { + if (!meshAuthToken) { + return { + success: false, + error: "mesh sync requires AGENTMEMORY_SECRET", + }; + } + if (!data || typeof data !== "object") { + data = {}; + } + + const direction = data.direction || "both"; + let peers: MeshPeer[]; + + if (data.peerId) { + const peer = await kv.get(KV.mesh, data.peerId); + if (!peer) return { success: false, error: "peer not found" }; + peers = [peer]; + } else { + peers = await kv.list(KV.mesh); + } + + const results: Array<{ + peerId: string; + peerName: string; + pushed: number; + pulled: number; + errors: string[]; + }> = []; + + for (const peer of peers) { + const result = { + peerId: peer.id, + peerName: peer.name, + pushed: 0, + pulled: 0, + errors: [] as string[], + }; + + peer.status = "syncing"; + await kv.set(KV.mesh, peer.id, peer); + await recordAudit(kv, "mesh_sync", "mem::mesh-sync", [peer.id], { + action: "mesh.sync.start", + direction, + scopes: data.scopes || peer.sharedScopes, + }); + + const scopes = data.scopes || peer.sharedScopes; + + try { + if (!(await isAllowedUrl(peer.url))) { + result.errors.push("peer URL blocked: private/local address not allowed"); + peer.status = "error"; + await kv.set(KV.mesh, peer.id, peer); + await recordAudit(kv, "mesh_sync", "mem::mesh-sync", [peer.id], { + action: "mesh.sync.error", + error: "peer URL blocked: private/local address not allowed", + }); + results.push(result); + continue; + } + + if (direction === "push" || direction === "both") { + const pushData = await collectSyncData(kv, scopes, peer.lastSyncAt, peer.syncFilter); + try { + const response = await fetch(`${peer.url}/agentmemory/mesh/receive`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${meshAuthToken}`, + }, + body: JSON.stringify(pushData), + signal: AbortSignal.timeout(30000), + redirect: "error", + }); + if (response.ok) { + const body = (await response.json()) as { accepted: number }; + result.pushed = body.accepted || 0; + } else { + result.errors.push(`push failed: HTTP ${response.status}`); + } + } catch (err) { + result.errors.push(`push failed: ${String(err)}`); + } + } + + if (direction === "pull" || direction === "both") { + try { + const response = await fetch( + `${peer.url}/agentmemory/mesh/export?since=${peer.lastSyncAt || ""}`, + { + headers: { + Authorization: `Bearer ${meshAuthToken}`, + }, + signal: AbortSignal.timeout(30000), + redirect: "error", + }, + ); + if (response.ok) { + const pullData = (await response.json()) as { + memories?: Memory[]; + actions?: Action[]; + }; + result.pulled = await applySyncData(kv, pullData, scopes); + } else { + result.errors.push(`pull failed: HTTP ${response.status}`); + } + } catch (err) { + result.errors.push(`pull failed: ${String(err)}`); + } + } + + peer.status = result.errors.length > 0 ? "error" : "connected"; + if (result.errors.length === 0) { + peer.lastSyncAt = new Date().toISOString(); + } + } catch (err) { + peer.status = "disconnected"; + result.errors.push(String(err)); + } + + await kv.set(KV.mesh, peer.id, peer); + await recordAudit(kv, "mesh_sync", "mem::mesh-sync", [peer.id], { + action: result.errors.length > 0 ? "mesh.sync.error" : "mesh.sync.complete", + direction, + scopes, + pushed: result.pushed, + pulled: result.pulled, + errors: result.errors, + lastSyncAt: peer.lastSyncAt, + }); + results.push(result); + } + + return { success: true, results }; + }, + ); + + sdk.registerFunction("mem::mesh-receive", + async (data: MeshSyncPayload) => { + if (!data || typeof data !== "object") { + return { success: false, error: "payload required" }; + } + let accepted = 0; + + accepted += await lwwMergeList(kv, KV.memories, data.memories, "mem:memory", "updatedAt"); + accepted += await lwwMergeList(kv, KV.actions, data.actions, "mem:action", "updatedAt"); + accepted += await lwwMergeList(kv, KV.semantic, data.semantic, "mem:semantic", "updatedAt"); + accepted += await lwwMergeList(kv, KV.procedural, data.procedural, "mem:procedural", "updatedAt"); + if (data.relations && Array.isArray(data.relations)) { + for (const rel of data.relations) { + if (!rel.sourceId || !rel.targetId || !rel.type) continue; + const relKey = `${rel.sourceId}:${rel.targetId}:${rel.type}`; + await withKeyedLock(`mem:relation:${relKey}`, async () => { + const existing = await kv.get(KV.relations, relKey); + if (!existing) { + await kv.set(KV.relations, relKey, rel); + await recordAudit(kv, "mesh_sync", "mem::mesh-receive", [relKey], { + action: "mesh.receive.relation", + accepted: true, + }); + accepted++; + } + }); + } + } + accepted += await lwwMergeGraphNodes(kv, data.graphNodes); + accepted += await lwwMergeList(kv, KV.graphEdges, data.graphEdges, "mem:gedge", "createdAt"); + await recordAudit(kv, "mesh_sync", "mem::mesh-receive", [], { + action: "mesh.receive", + accepted, + }); + + return { success: true, accepted }; + }, + ); + + sdk.registerFunction("mem::mesh-remove", + async (data: { peerId: string }) => { + if (!data || typeof data !== "object" || !data.peerId) { + return { success: false, error: "peerId is required" }; + } + await kv.delete(KV.mesh, data.peerId); + await recordAudit(kv, "mesh_sync", "mem::mesh-remove", [data.peerId], { + action: "mesh.remove", + }); + return { success: true }; + }, + ); +} + +function deltaFilter( + items: T[], + sinceTime: number, + tsField: "updatedAt" | "createdAt", +): T[] { + return items.filter( + (item) => new Date((item as Record)[tsField] as string).getTime() > sinceTime, + ); +} + +async function collectSyncData( + kv: StateKV, + scopes: string[], + since?: string, + syncFilter?: { project?: string }, +): Promise { + const result: MeshSyncPayload = {}; + const parsed = since ? new Date(since).getTime() : 0; + const sinceTime = Number.isNaN(parsed) ? 0 : parsed; + + if (scopes.includes("memories")) { + const all = await kv.list(KV.memories); + result.memories = deltaFilter(all, sinceTime, "updatedAt"); + } + + if (scopes.includes("actions")) { + let all = await kv.list(KV.actions); + if (syncFilter?.project) { + all = all.filter((a) => a.project === syncFilter.project); + } + result.actions = deltaFilter(all, sinceTime, "updatedAt"); + } + + const projectScoped = !!syncFilter?.project; + + if (scopes.includes("semantic") && !projectScoped) { + const all = await kv.list(KV.semantic); + result.semantic = deltaFilter(all, sinceTime, "updatedAt"); + } + + if (scopes.includes("procedural") && !projectScoped) { + const all = await kv.list(KV.procedural); + result.procedural = deltaFilter(all, sinceTime, "updatedAt"); + } + + if (scopes.includes("relations") && !projectScoped) { + const all = await kv.list(KV.relations); + result.relations = deltaFilter(all, sinceTime, "createdAt"); + } + + if (scopes.includes("graph:nodes") && !projectScoped) { + const all = await kv.list(KV.graphNodes); + result.graphNodes = all.filter( + (n) => new Date(graphNodeTs(n)).getTime() > sinceTime, + ); + } + + if (scopes.includes("graph:edges") && !projectScoped) { + const all = await kv.list(KV.graphEdges); + result.graphEdges = deltaFilter(all, sinceTime, "createdAt"); + } + + return result; +} + +async function applySyncData( + kv: StateKV, + data: MeshSyncPayload, + scopes: string[], +): Promise { + let applied = 0; + + if (scopes.includes("memories")) { + applied += await lwwMergeList(kv, KV.memories, data.memories, "mem:memory", "updatedAt"); + } + if (scopes.includes("actions")) { + applied += await lwwMergeList(kv, KV.actions, data.actions, "mem:action", "updatedAt"); + } + if (scopes.includes("semantic")) { + applied += await lwwMergeList(kv, KV.semantic, data.semantic, "mem:semantic", "updatedAt"); + } + if (scopes.includes("procedural")) { + applied += await lwwMergeList(kv, KV.procedural, data.procedural, "mem:procedural", "updatedAt"); + } + if (scopes.includes("relations") && data.relations) { + for (const rel of data.relations) { + if (!rel.sourceId || !rel.targetId || !rel.type) continue; + const relKey = `${rel.sourceId}:${rel.targetId}:${rel.type}`; + const wrote = await withKeyedLock(`mem:relation:${relKey}`, async () => { + const existing = await kv.get(KV.relations, relKey); + if (!existing) { + await kv.set(KV.relations, relKey, rel); + return true; + } + return false; + }); + if (wrote) applied++; + } + } + if (scopes.includes("graph:nodes")) { + applied += await lwwMergeGraphNodes(kv, data.graphNodes); + } + if (scopes.includes("graph:edges")) { + applied += await lwwMergeList(kv, KV.graphEdges, data.graphEdges, "mem:gedge", "createdAt"); + } + + return applied; +} diff --git a/src/functions/migrate-vector-index.ts b/src/functions/migrate-vector-index.ts new file mode 100644 index 0000000..058eab8 --- /dev/null +++ b/src/functions/migrate-vector-index.ts @@ -0,0 +1,152 @@ +import type { EmbeddingProvider, CompressedObservation, Memory } from "../types.js"; +import { VectorIndex } from "../state/vector-index.js"; +import { KV } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { logger } from "../logger.js"; + +export interface MigrateVectorIndexResult { + success: boolean; + totalProcessed: number; + failed: number; + vectorSize: number; + failedSessions: string[]; +} + +// Validate one embedding's shape against the provider's declared dimensions +// before pushing it into the index. Mirrors the symmetric guard in +// search.ts::vectorIndexAddGuarded — without this, a misconfigured +// provider returning the wrong-length Float32Array would silently corrupt +// the rebuilt index (per #248). +function isValidEmbedding( + embedding: Float32Array, + provider: EmbeddingProvider, + context: { kind: "memory" | "observation"; id: string }, +): boolean { + if (embedding.length !== provider.dimensions) { + logger.warn("migrateVectorIndex: dimension mismatch — skipping", { + kind: context.kind, + id: context.id, + provider: provider.name, + expected: provider.dimensions, + received: embedding.length, + }); + return false; + } + return true; +} + +// Rebuilds a fresh VectorIndex against `newProvider`, re-embedding every +// memory and per-session observation in `kv`. Each phase (memories + +// per-session observations) is isolated — a single session that throws +// on kv.list or embedBatch increments `failed` and appends to +// `failedSessions`, but the migration continues. Returns a structured +// result the caller can inspect to decide whether to swap the index in. +export async function migrateVectorIndex( + kv: StateKV, + newProvider: EmbeddingProvider, +): Promise { + const newIndex = new VectorIndex(); + let failed = 0; + let processed = 0; + const failedSessions: string[] = []; + + // --- Memories phase ---------------------------------------------------- + // textMems is declared outside the try so the catch can attribute the + // batch-level failure to the correct number of missed embeddings (the + // size of the batch we were about to embed), not a flat +1. + let textMems: Memory[] = []; + try { + const memories = await kv.list(KV.memories); + textMems = memories.filter( + (m) => m.isLatest !== false && m.title && m.content && m.content.trim() !== "", + ); + const texts = textMems.map((m) => m.title + " " + m.content); + + if (texts.length > 0) { + const embeddings = await newProvider.embedBatch(texts); + for (let i = 0; i < textMems.length; i++) { + if (!isValidEmbedding(embeddings[i], newProvider, { kind: "memory", id: textMems[i].id })) { + failed++; + continue; + } + newIndex.add( + textMems[i].id, + textMems[i].sessionIds[0] ?? "memory", + embeddings[i], + ); + processed++; + } + } + } catch (err) { + // If kv.list threw before textMems was populated, the batch size is + // unknown — count as +1 (something failed but we don't know what). + // If embedBatch threw, textMems.length is the real number of missed + // memories. Caller relying on `failed` for retry math needs this + // attribution to be accurate. + const missed = textMems.length > 0 ? textMems.length : 1; + logger.warn("migrateVectorIndex: failed to re-embed memories", { + missed, + error: err instanceof Error ? err.message : String(err), + }); + failed += missed; + } + + // --- Observations phase (per-session isolation) ------------------------ + // Without per-session try/catch, one bad session (kv.list throws, + // embedBatch rejects, etc.) would abort every later session and silently + // truncate the migration. Each session now has its own boundary; failures + // increment `failed`, append the session id to failedSessions, and the + // loop moves on. + let sessions: Array<{ id: string }>; + try { + sessions = await kv.list<{ id: string }>(KV.sessions); + } catch (err) { + logger.warn("migrateVectorIndex: failed to list sessions", { + error: err instanceof Error ? err.message : String(err), + }); + failed++; + // Distinguish a list-sessions failure (catastrophic: no sessions + // could be enumerated) from a per-session failure (one specific id + // threw). Without the marker the caller sees failed=N + an empty + // failedSessions list and can't tell apart "0 sessions, all OK" + // from "kv.list itself blew up". + failedSessions.push(""); + return { success: false, totalProcessed: processed, failed, vectorSize: newIndex.size, failedSessions }; + } + + for (const session of sessions) { + try { + const observations = await kv.list( + KV.observations(session.id), + ); + const textObs = observations.filter((o) => o.title); + const texts = textObs.map((o) => o.title + " " + (o.narrative || "")); + if (texts.length === 0) continue; + + const embeddings = await newProvider.embedBatch(texts); + for (let i = 0; i < textObs.length; i++) { + if (!isValidEmbedding(embeddings[i], newProvider, { kind: "observation", id: textObs[i].id })) { + failed++; + continue; + } + newIndex.add(textObs[i].id, textObs[i].sessionId, embeddings[i]); + processed++; + } + } catch (err) { + logger.warn("migrateVectorIndex: failed to re-embed session", { + sessionId: session.id, + error: err instanceof Error ? err.message : String(err), + }); + failed++; + failedSessions.push(session.id); + } + } + + return { + success: failed === 0, + totalProcessed: processed, + failed, + vectorSize: newIndex.size, + failedSessions, + }; +} diff --git a/src/functions/migrate.ts b/src/functions/migrate.ts new file mode 100644 index 0000000..c2039ea --- /dev/null +++ b/src/functions/migrate.ts @@ -0,0 +1,247 @@ +import type { ISdk } from "iii-sdk"; +import { resolve } from "node:path"; +import { homedir } from "node:os"; +import { KV, generateId } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import type { + Memory, + Session, + CompressedObservation, + SessionSummary, +} from "../types.js"; +import { logger } from "../logger.js"; + +const ALLOWED_DIRS = [resolve(homedir(), ".agentmemory")]; + +function isAllowedPath(dbPath: string): boolean { + const resolved = resolve(dbPath); + return ALLOWED_DIRS.some((dir) => resolved.startsWith(dir + "/")); +} + +// Infer memory project from the majority project of its associated sessions. +// Returns { updated, skipped } — safe to run repeatedly (idempotent). +export async function inferMemoryProjects( + kv: StateKV, + dryRun = false, +): Promise<{ updated: number; skipped: number; ambiguous: number }> { + const memories = await kv.list(KV.memories); + const sessionCache = new Map(); + + const loadSession = async (sid: string): Promise => { + if (sessionCache.has(sid)) return sessionCache.get(sid)!; + const s = await kv.get(KV.sessions, sid).catch(() => null); + sessionCache.set(sid, s); + return s; + }; + + let updated = 0; + let skipped = 0; + let ambiguous = 0; + + for (const memory of memories) { + if (memory.project) { + skipped++; + continue; + } + + const sessionIds = memory.sessionIds ?? []; + if (sessionIds.length === 0) { + ambiguous++; + continue; + } + + const projects: string[] = []; + for (const sid of sessionIds) { + const session = await loadSession(sid); + if (session?.project) projects.push(session.project); + } + + if (projects.length === 0) { + ambiguous++; + continue; + } + + // Majority-vote: count frequency of each project value. + const freq = new Map(); + for (const p of projects) freq.set(p, (freq.get(p) ?? 0) + 1); + const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]); + const [topProject, topCount] = sorted[0]; + + // Require a strict majority (> 50%) to avoid misattributing a memory + // that was genuinely built from sessions across multiple projects. + if (topCount <= projects.length / 2 && sorted.length > 1) { + ambiguous++; + continue; + } + + if (!dryRun) { + memory.project = topProject; + await kv.set(KV.memories, memory.id, memory); + } + updated++; + } + + logger.info("inferMemoryProjects complete", { updated, skipped, ambiguous, dryRun }); + return { updated, skipped, ambiguous }; +} + +export function registerMigrateFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::migrate", + async (data: { dbPath?: string; step?: string; dryRun?: boolean }) => { + // In-place KV migration steps (no SQLite dependency). + if (data.step === "infer-memory-projects") { + const dryRun = data.dryRun ?? false; + logger.info("Migration step: infer-memory-projects", { dryRun }); + const result = await inferMemoryProjects(kv, dryRun); + return { success: true, step: "infer-memory-projects", ...result }; + } + + if (!data.dbPath) { + return { + success: false, + error: "Either step or dbPath is required", + }; + } + + logger.info("Migration started", { dbPath: data.dbPath }); + + if (!isAllowedPath(data.dbPath)) { + return { + success: false, + error: `Path not allowed. Must be under: ${ALLOWED_DIRS.join(", ")}`, + }; + } + + let Database: any; + try { + // @ts-expect-error optional dependency + Database = (await import("better-sqlite3")).default; + } catch { + return { + success: false, + error: + "better-sqlite3 not installed. Run: npm install better-sqlite3", + }; + } + + const fs = await import("node:fs"); + if (!fs.existsSync(data.dbPath)) { + return { success: false, error: `Database not found: ${data.dbPath}` }; + } + + let db: any; + try { + db = Database(data.dbPath, { readonly: true }); + let sessionCount = 0; + let obsCount = 0; + let summaryCount = 0; + + const sessions = db + .prepare("SELECT * FROM sessions ORDER BY created_at DESC") + .all() as any[]; + for (const row of sessions) { + const session: Session = { + id: row.session_id || row.id, + project: row.project_path || row.project || "unknown", + cwd: row.cwd || row.project_path || "", + startedAt: + row.created_at || row.started_at || new Date().toISOString(), + endedAt: row.ended_at || row.updated_at, + status: "completed", + observationCount: 0, + }; + await kv.set(KV.sessions, session.id, session); + sessionCount++; + } + + let observations: any[] = []; + try { + observations = db + .prepare("SELECT * FROM observations ORDER BY created_at ASC") + .all() as any[]; + } catch { + try { + observations = db + .prepare( + "SELECT * FROM compressed_observations ORDER BY created_at ASC", + ) + .all() as any[]; + } catch { + logger.warn("No observation tables found"); + } + } + + for (const row of observations) { + const sessionId = row.session_id || "migrated"; + const obs: CompressedObservation = { + id: row.id || generateId("mig"), + sessionId, + timestamp: row.created_at || new Date().toISOString(), + type: row.type || "other", + title: row.title || row.summary || "Migrated observation", + subtitle: row.subtitle, + facts: safeJsonParse(row.facts, []), + narrative: row.narrative || row.content || "", + concepts: safeJsonParse(row.concepts, []), + files: safeJsonParse(row.files, []), + importance: row.importance || 5, + }; + await kv.set(KV.observations(sessionId), obs.id, obs); + obsCount++; + } + + let summaries: any[] = []; + try { + summaries = db + .prepare("SELECT * FROM session_summaries") + .all() as any[]; + } catch { + logger.warn("No summaries table found"); + } + + for (const row of summaries) { + const summary: SessionSummary = { + sessionId: row.session_id, + project: row.project || "unknown", + createdAt: row.created_at || new Date().toISOString(), + title: row.title || "Migrated session", + narrative: row.narrative || row.summary || "", + keyDecisions: safeJsonParse(row.key_decisions, []), + filesModified: safeJsonParse(row.files_modified, []), + concepts: safeJsonParse(row.concepts, []), + observationCount: row.observation_count || 0, + }; + await kv.set(KV.summaries, row.session_id, summary); + summaryCount++; + } + + logger.info("Migration complete", { + sessionCount, + obsCount, + summaryCount, + }); + return { success: true, sessionCount, obsCount, summaryCount }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Migration failed", { error: msg }); + return { success: false, error: "Migration failed" }; + } finally { + try { + if (db) db.close(); + } catch {} + } + }, + ); +} + +function safeJsonParse(value: unknown, fallback: T): T { + if (Array.isArray(value)) return value as T; + if (typeof value === "string") { + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } + } + return fallback; +} diff --git a/src/functions/observe.ts b/src/functions/observe.ts new file mode 100644 index 0000000..8ad4ba0 --- /dev/null +++ b/src/functions/observe.ts @@ -0,0 +1,345 @@ +import { TriggerAction, type ISdk } from "iii-sdk"; +import type { RawObservation, HookPayload } from "../types.js"; +import { KV, STREAM, generateId } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { stripPrivateData } from "./privacy.js"; +import { DedupMap } from "./dedup.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { isAutoCompressEnabled } from "../config.js"; +import { buildSyntheticCompression } from "./compress-synthetic.js"; +import { getSearchIndex, vectorIndexAddGuarded } from "./search.js"; +import { getAgentId } from "../config.js"; +import { logger } from "../logger.js"; +import { saveImageToDisk } from "../utils/image-store.js"; + +export function extractImage(d: unknown): string | undefined { + if (!d) return undefined; + if (typeof d === "string") { + if (d.startsWith("data:image/") || d.startsWith("iVBORw0KGgo") || d.startsWith("/9j/")) { + return d; + } + return undefined; + } + if (typeof d === "object" && d !== null) { + const obj = d as Record; + if (typeof obj["image_data"] === "string") return obj["image_data"]; + if (typeof obj["image_path"] === "string") return obj["image_path"]; + if (typeof obj["imageBase64"] === "string") return obj["imageBase64"]; + if (typeof obj["imagePath"] === "string") return obj["imagePath"]; + + for (const key of Object.keys(obj)) { + const match = extractImage(obj[key]); + if (match) return match; + } + } + return undefined; +} + +export function registerObserveFunction( + sdk: ISdk, + kv: StateKV, + dedupMap?: DedupMap, + maxObservationsPerSession?: number, +): void { + sdk.registerFunction("mem::observe", + async (payload: HookPayload) => { + + if ( + !payload?.sessionId || + typeof payload.sessionId !== "string" || + !payload.hookType || + typeof payload.hookType !== "string" || + !payload.timestamp || + typeof payload.timestamp !== "string" + ) { + return { + success: false, + error: + "Invalid payload: sessionId, hookType, and timestamp are required", + }; + } + + const obsId = generateId("obs"); + + let dedupHash: string | undefined; + if (dedupMap) { + const d = + typeof payload.data === "object" && payload.data !== null + ? (payload.data as Record) + : {}; + const toolName = (d["tool_name"] as string) || payload.hookType; + dedupHash = dedupMap.computeHash( + payload.sessionId, + toolName, + d["tool_input"], + ); + if (dedupMap.isDuplicate(dedupHash)) { + return { deduplicated: true, sessionId: payload.sessionId }; + } + } + + let sanitizedRaw: unknown = payload.data; + try { + const jsonStr = JSON.stringify(payload.data); + const sanitized = stripPrivateData(jsonStr); + sanitizedRaw = JSON.parse(sanitized); + } catch { + sanitizedRaw = stripPrivateData(String(payload.data)); + } + + const raw: RawObservation = { + id: obsId, + sessionId: payload.sessionId, + timestamp: payload.timestamp, + hookType: payload.hookType, + raw: sanitizedRaw, + }; + + let extractedImage: string | undefined; + + if (typeof sanitizedRaw === "object" && sanitizedRaw !== null) { + const d = sanitizedRaw as Record; + if ( + payload.hookType === "post_tool_use" || + payload.hookType === "post_tool_failure" + ) { + raw.toolName = d["tool_name"] as string | undefined; + raw.toolInput = d["tool_input"]; + raw.toolOutput = d["tool_output"] || d["error"]; + } + if (payload.hookType === "prompt_submit") { + raw.userPrompt = d["prompt"] as string | undefined; + } + + extractedImage = extractImage(sanitizedRaw); + if (extractedImage) { + raw.modality = (raw.toolInput || raw.toolOutput || raw.userPrompt) ? "mixed" : "image"; + } + } else if (typeof sanitizedRaw === "string") { + extractedImage = extractImage(sanitizedRaw); + if (extractedImage) { + raw.modality = "image"; + } + } + + const pendingImageData = extractedImage; + + return withKeyedLock(`obs:${payload.sessionId}`, async () => { + if (maxObservationsPerSession && maxObservationsPerSession > 0) { + const existing = await kv.list(KV.observations(payload.sessionId)); + if (existing.length >= maxObservationsPerSession) { + return { + success: false, + error: `Session observation limit reached (${maxObservationsPerSession})`, + }; + } + } + + // Existing session is the source of truth for agentId (even + // undefined). Env AGENT_ID only fires when no session row + // exists yet — otherwise an unscoped session would get + // retroactively scoped by a later AGENT_ID export. + const existingSession = await kv.get<{ + agentId?: string; + observationCount?: number; + firstPrompt?: string; + }>(KV.sessions, payload.sessionId); + const inheritedAgentId = existingSession + ? existingSession.agentId + : getAgentId(); + if (inheritedAgentId) { + raw.agentId = inheritedAgentId; + } + + if (pendingImageData && (pendingImageData.startsWith("data:image/") || pendingImageData.startsWith("iVBORw0KGgo") || pendingImageData.startsWith("/9j/"))) { + const { filePath, bytesWritten } = await saveImageToDisk(pendingImageData); + raw.imageData = filePath; + const { incrementImageRef } = await import("./image-refs.js"); + await incrementImageRef(kv, filePath); + sdk.trigger({ + function_id: "mem::disk-size-delta", + payload: { deltaBytes: bytesWritten }, + action: TriggerAction.Void(), + }); + if (process.env["AGENTMEMORY_IMAGE_EMBEDDINGS"] === "true") { + sdk.trigger({ + function_id: "mem::vision-embed", + payload: { + imageRef: filePath, + sessionId: payload.sessionId, + observationId: obsId, + }, + action: TriggerAction.Void(), + }); + } + } + + try { + + await kv.set(KV.observations(payload.sessionId), obsId, raw); + + } catch (error) { + if (raw.imageData) { + // Roll back the ref taken above. decrementImageRef deletes the file + // only when no other observation still references it (deduped images + // survive) and emits the disk-size delta itself — deleting the file + // directly here would orphan shared images and leave a stale ref. + // If the rollback itself fails, log it but still surface the + // original write error (the more useful failure to diagnose). + try { + const { decrementImageRef } = await import("./image-refs.js"); + await decrementImageRef(kv, sdk, raw.imageData); + } catch (rollbackError) { + logger.error("Failed to roll back image ref after observation write failure", { + imageRef: raw.imageData, + error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }); + } + } + throw error; + } + + if (dedupMap && dedupHash) { + dedupMap.record(dedupHash); + } + + await sdk.trigger({ + function_id: "stream::set", + payload: { + stream_name: STREAM.name, + group_id: STREAM.group(payload.sessionId), + item_id: obsId, + data: { type: "raw", observation: raw }, + }, + }); + + await sdk.trigger({ + function_id: "stream::send", + payload: { + stream_name: STREAM.name, + group_id: STREAM.viewerGroup, + id: `raw-${obsId}`, + type: "raw_observation", + data: { type: "raw", observation: raw, sessionId: payload.sessionId }, + }, + action: TriggerAction.Void(), + }); + + const session = existingSession; + if (session) { + const updates: Array<{ type: "set"; path: string; value: unknown }> = [ + { type: "set", path: "updatedAt", value: new Date().toISOString() }, + { + type: "set", + path: "observationCount", + value: (session.observationCount || 0) + 1, + }, + ]; + if (!session.firstPrompt && typeof raw.userPrompt === "string") { + const trimmed = raw.userPrompt.replace(/\s+/g, " ").trim(); + if (trimmed.length > 0) { + updates.push({ + type: "set", + path: "firstPrompt", + value: trimmed.slice(0, 200), + }); + } + } + await kv.update(KV.sessions, payload.sessionId, updates); + } else if ( + typeof payload.project === "string" && + payload.project.trim().length > 0 && + typeof payload.cwd === "string" && + payload.cwd.trim().length > 0 + ) { + // OpenCode (and any plugin that skips POST /session/start) + // can fire observations before the session record exists. Without + // an implicit create, those observations stack up but + // `memory_sessions` never lists them, and summarize bails with + // "Session not found for summarize". Create the session now from + // the observation payload — but only when project + cwd are + // present (HookPayload contract). Older test payloads without + // those fields keep their original no-op behaviour. + const trimmedPrompt = + typeof raw.userPrompt === "string" + ? raw.userPrompt.replace(/\s+/g, " ").trim().slice(0, 200) + : undefined; + const ts = new Date().toISOString(); + await kv.set(KV.sessions, payload.sessionId, { + id: payload.sessionId, + project: payload.project, + cwd: payload.cwd, + startedAt: payload.timestamp ?? ts, + updatedAt: ts, + status: "active", + observationCount: 1, + ...(inheritedAgentId ? { agentId: inheritedAgentId } : {}), + ...(trimmedPrompt && trimmedPrompt.length > 0 + ? { firstPrompt: trimmedPrompt } + : {}), + }); + } + + // Per-observation LLM compression is opt-in as of 0.8.8. + // Default path: build a zero-LLM synthetic compression so recall + // and BM25 search still work without burning the user's Claude + // token allocation on every tool invocation. + if (isAutoCompressEnabled()) { + await sdk.trigger({ + function_id: "mem::compress", + payload: { + observationId: obsId, + sessionId: payload.sessionId, + raw, + }, + action: TriggerAction.Void(), + }); + } else { + const synthetic = buildSyntheticCompression(raw); + await kv.set( + KV.observations(payload.sessionId), + obsId, + synthetic, + ); + getSearchIndex().add(synthetic); + await vectorIndexAddGuarded( + synthetic.id, + synthetic.sessionId, + synthetic.title + " " + (synthetic.narrative || ""), + { kind: "synthetic", logId: synthetic.id }, + ); + await sdk.trigger({ + function_id: "stream::set", + payload: { + stream_name: STREAM.name, + group_id: STREAM.group(payload.sessionId), + item_id: obsId, + data: { type: "compressed", observation: synthetic }, + }, + }); + await sdk.trigger({ + function_id: "stream::set", + payload: { + stream_name: STREAM.name, + group_id: STREAM.viewerGroup, + item_id: obsId, + data: { + type: "compressed", + observation: synthetic, + sessionId: payload.sessionId, + }, + }, + }); + } + + logger.info("Observation captured", { + obsId, + sessionId: payload.sessionId, + hook: payload.hookType, + compress: isAutoCompressEnabled() ? "llm" : "synthetic", + }); + return { observationId: obsId }; + }); + }, + ); +} diff --git a/src/functions/obsidian-export.ts b/src/functions/obsidian-export.ts new file mode 100644 index 0000000..0e81948 --- /dev/null +++ b/src/functions/obsidian-export.ts @@ -0,0 +1,432 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import { homedir } from "node:os"; +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import type { + Memory, + Lesson, + Crystal, + Session, +} from "../types.js"; +import { recordAudit } from "./audit.js"; +const DEFAULT_EXPORT_ROOT = join(homedir(), ".agentmemory"); + +function getExportRoot(): string { + return resolve(process.env["AGENTMEMORY_EXPORT_ROOT"] || DEFAULT_EXPORT_ROOT); +} + +function resolveVaultDir(vaultDir?: string): string | null { + const root = getExportRoot(); + const resolved = resolve(vaultDir || join(root, "vault")); + if (resolved === root || resolved.startsWith(root + sep)) { + return resolved; + } + return null; +} + +function sanitize(name: string): string { + return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").slice(0, 100); +} + +// #729: every record helper used to crash on null/undefined fields, +// poisoning the whole export. These helpers stay strict about types but +// return sensible fallbacks instead of throwing. +function hasExportId( + item: T | null | undefined, +): item is T & { id: string } { + return !!item && typeof (item as { id?: unknown }).id === "string" && (item as { id: string }).id.length > 0; +} + +function safeArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +function safeString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function safeTimestamp(value: unknown): number { + if (typeof value !== "string") return 0; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function toFrontmatter(obj: Record): string { + const lines = ["---"]; + for (const [key, value] of Object.entries(obj)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + lines.push(`${key}: [${value.map((v) => JSON.stringify(String(v))).join(", ")}]`); + } else { + lines.push(`${key}: ${JSON.stringify(value)}`); + } + } + lines.push("---"); + return lines.join("\n"); +} + +function memoryToMd(m: Memory): string { + const concepts = safeArray(m.concepts); + const files = safeArray(m.files); + const relatedIds = safeArray(m.relatedIds); + const supersedes = safeArray(m.supersedes); + const title = safeString(m.title, m.id); + + const fm = toFrontmatter({ + id: m.id, + type: m.type, + created: m.createdAt, + updated: m.updatedAt, + strength: m.strength, + version: m.version, + concepts, + files, + }); + + const relatedLines = relatedIds.map((id) => `- [[${id}]]`).join("\n"); + const supersedesLines = supersedes + .map((id) => `- [[${id}]] (superseded)`) + .join("\n"); + + const sections = [ + fm, + "", + `# ${title}`, + "", + safeString(m.content), + ]; + + if (concepts.length > 0) { + sections.push( + "", + "## Concepts", + concepts.map((c) => `#${c.replace(/\s+/g, "-")}`).join(" "), + ); + } + if (relatedLines) { + sections.push("", "## Related", relatedLines); + } + if (supersedesLines) { + sections.push("", "## Supersedes", supersedesLines); + } + + return sections.join("\n"); +} + +function lessonToMd(l: Lesson): string { + const tags = safeArray(l.tags); + const sourceIds = safeArray(l.sourceIds); + const content = safeString(l.content); + const headline = content ? content.slice(0, 80) : l.id; + + const fm = toFrontmatter({ + id: l.id, + type: "lesson", + source: l.source, + confidence: l.confidence, + reinforcements: l.reinforcements, + created: l.createdAt, + updated: l.updatedAt, + project: l.project, + tags, + decayRate: l.decayRate, + }); + + const sourceLinks = sourceIds.map((id) => `- [[${id}]]`).join("\n"); + + const sections = [ + fm, + "", + `# Lesson: ${headline}`, + "", + content, + ]; + + if (l.context) { + sections.push("", "## Context", l.context); + } + if (tags.length > 0) { + sections.push( + "", + "## Tags", + tags.map((t) => `#${t.replace(/\s+/g, "-")}`).join(" "), + ); + } + if (sourceLinks) { + sections.push("", "## Sources", sourceLinks); + } + + return sections.join("\n"); +} + +function crystalToMd(c: Crystal): string { + const keyOutcomes = safeArray(c.keyOutcomes); + const lessons = safeArray(c.lessons); + const filesAffected = safeArray(c.filesAffected); + const sourceActionIds = safeArray(c.sourceActionIds); + const narrative = safeString(c.narrative); + const headline = narrative ? narrative.slice(0, 80) : c.id; + + const fm = toFrontmatter({ + id: c.id, + type: "crystal", + created: c.createdAt, + project: c.project, + sessionId: c.sessionId, + filesAffected, + }); + + const actionLinks = sourceActionIds.map((id) => `- [[${id}]]`).join("\n"); + + const sections = [ + fm, + "", + `# Crystal: ${headline}`, + "", + narrative, + "", + "## Key Outcomes", + ...keyOutcomes.map((o) => `- ${o}`), + ]; + + if (lessons.length > 0) { + sections.push("", "## Lessons", ...lessons.map((l) => `- ${l}`)); + } + if (filesAffected.length > 0) { + sections.push("", "## Files", ...filesAffected.map((f) => `- \`${f}\``)); + } + if (actionLinks) { + sections.push("", "## Source Actions", actionLinks); + } + + return sections.join("\n"); +} + +function sessionToMd(s: Session): string { + const project = safeString(s.project, "unknown"); + const status = safeString(s.status, "unknown"); + const startedAt = safeString(s.startedAt, ""); + const cwd = safeString(s.cwd, ""); + + const fm = toFrontmatter({ + id: s.id, + type: "session", + project, + status, + started: startedAt || undefined, + ended: s.endedAt, + observations: s.observationCount, + }); + + return [ + fm, + "", + `# Session: ${project}`, + "", + `**Status:** ${status}`, + startedAt ? `**Started:** ${startedAt}` : "", + s.endedAt ? `**Ended:** ${s.endedAt}` : "", + `**Observations:** ${s.observationCount ?? 0}`, + cwd ? `**CWD:** \`${cwd}\`` : "", + ] + .filter(Boolean) + .join("\n"); +} + +interface ExportError { + id: string; + path: string; + error: string; +} + +export function registerObsidianExportFunction( + sdk: ISdk, + kv: StateKV, +): void { + sdk.registerFunction("mem::obsidian-export", + async (data: { vaultDir?: string; types?: string[] } | undefined) => { + if (!data || typeof data !== "object") { + return { success: false, error: "payload is required" }; + } + if (data.vaultDir !== undefined && typeof data.vaultDir !== "string") { + return { success: false, error: "vaultDir must be a string" }; + } + if (data.types !== undefined) { + if ( + !Array.isArray(data.types) || + !data.types.every((t): t is string => typeof t === "string") + ) { + return { success: false, error: "types must be an array of strings" }; + } + } + + const vaultDir = resolveVaultDir(data.vaultDir); + if (!vaultDir) { + return { + success: false, + error: `vaultDir must be inside ${getExportRoot()}`, + }; + } + const exportTypes = new Set( + data.types ?? ["memories", "lessons", "crystals", "sessions"], + ); + + const dirs = { + memories: join(vaultDir, "memories"), + lessons: join(vaultDir, "lessons"), + crystals: join(vaultDir, "crystals"), + sessions: join(vaultDir, "sessions"), + }; + + // Outer try/catch keeps the function from ever throwing out to the + // iii engine's HTTP serializer; #729 surfaced an unhandled + // TypeError as `{"error":"[object Object]"}`. With this guard the + // worst case is `{success: false, error: }`. + try { + await Promise.all( + Object.values(dirs).map((dir) => mkdir(dir, { recursive: true })), + ); + + const stats = { memories: 0, lessons: 0, crystals: 0, sessions: 0 }; + const errors: ExportError[] = []; + const memoryMoc: string[] = []; + const lessonMoc: string[] = []; + const crystalMoc: string[] = []; + const sessionMoc: string[] = []; + + const [memories, lessons, crystals, sessions] = await Promise.all([ + exportTypes.has("memories") ? kv.list(KV.memories) : Promise.resolve([] as Memory[]), + exportTypes.has("lessons") ? kv.list(KV.lessons) : Promise.resolve([] as Lesson[]), + exportTypes.has("crystals") ? kv.list(KV.crystals) : Promise.resolve([] as Crystal[]), + exportTypes.has("sessions") ? kv.list(KV.sessions) : Promise.resolve([] as Session[]), + ]); + + for (const m of memories.filter( + (m): m is Memory & { id: string } => hasExportId(m) && m.isLatest === true, + )) { + const filename = `${sanitize(m.id)}.md`; + const filepath = join(dirs.memories, filename); + try { + await writeFile(filepath, memoryToMd(m)); + stats.memories++; + memoryMoc.push( + `- [[memories/${sanitize(m.id)}|${safeString(m.title, m.id)}]] (${m.type}, strength: ${m.strength ?? 0})`, + ); + } catch (err) { + errors.push({ + id: m.id, + path: filepath, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + for (const l of lessons.filter( + (l): l is Lesson & { id: string } => hasExportId(l) && !l.deleted, + )) { + const filename = `${sanitize(l.id)}.md`; + const filepath = join(dirs.lessons, filename); + try { + await writeFile(filepath, lessonToMd(l)); + stats.lessons++; + const headline = safeString(l.content).slice(0, 60) || l.id; + lessonMoc.push( + `- [[lessons/${sanitize(l.id)}|${headline}]] (confidence: ${l.confidence ?? 0})`, + ); + } catch (err) { + errors.push({ + id: l.id, + path: filepath, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + for (const c of crystals.filter(hasExportId)) { + const filename = `${sanitize(c.id)}.md`; + const filepath = join(dirs.crystals, filename); + try { + await writeFile(filepath, crystalToMd(c)); + stats.crystals++; + const headline = safeString(c.narrative).slice(0, 60) || c.id; + crystalMoc.push(`- [[crystals/${sanitize(c.id)}|${headline}]]`); + } catch (err) { + errors.push({ + id: c.id, + path: filepath, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const recent = sessions + .filter(hasExportId) + .sort((a, b) => safeTimestamp(b.startedAt) - safeTimestamp(a.startedAt)) + .slice(0, 50); + for (const s of recent) { + const filename = `${sanitize(s.id)}.md`; + const filepath = join(dirs.sessions, filename); + try { + await writeFile(filepath, sessionToMd(s)); + stats.sessions++; + sessionMoc.push( + `- [[sessions/${sanitize(s.id)}|${safeString(s.project, "unknown")} (${safeString(s.status, "unknown")})]]`, + ); + } catch (err) { + errors.push({ + id: s.id, + path: filepath, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const exportedAt = new Date().toISOString(); + const moc = [ + "---", + "type: moc", + `exported: ${exportedAt}`, + "---", + "", + "# agentmemory vault", + "", + `Exported: ${exportedAt}`, + "", + `## Memories (${stats.memories})`, + ...memoryMoc, + "", + `## Lessons (${stats.lessons})`, + ...lessonMoc, + "", + `## Crystals (${stats.crystals})`, + ...crystalMoc, + "", + `## Sessions (${stats.sessions})`, + ...sessionMoc, + ].join("\n"); + + await writeFile(join(vaultDir, "MOC.md"), moc); + + await recordAudit(kv, "obsidian_export", "mem::obsidian-export", [], { + vaultDir, + stats, + }); + + return { + success: true, + exported: stats, + errors: errors.length > 0 ? errors : undefined, + vaultDir, + }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + vaultDir, + }; + } + }, + ); +} diff --git a/src/functions/patterns.ts b/src/functions/patterns.ts new file mode 100644 index 0000000..8d1365f --- /dev/null +++ b/src/functions/patterns.ts @@ -0,0 +1,134 @@ +import type { ISdk } from "iii-sdk"; +import type { CompressedObservation, Session } from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { logger } from "../logger.js"; + +interface Pattern { + type: "co_change" | "error_repeat" | "workflow"; + description: string; + files: string[]; + frequency: number; + sessions: string[]; +} + +export function registerPatternsFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::patterns", + async (data: { project?: string }) => { + const patterns: Pattern[] = []; + + const sessions = await kv.list(KV.sessions); + const filtered = data.project + ? sessions.filter((s) => s.project === data.project) + : sessions; + + const fileCoOccurrences = new Map(); + const fileSessionMap = new Map>(); + const errorPatterns = new Map< + string, + { count: number; sessions: Set } + >(); + + for (const session of filtered) { + const observations = await kv.list( + KV.observations(session.id), + ); + if (!observations.length) continue; + + const sessionFiles = new Set(); + for (const obs of observations) { + if (!obs.files) continue; + for (const f of obs.files) { + sessionFiles.add(f); + if (!fileSessionMap.has(f)) fileSessionMap.set(f, new Set()); + fileSessionMap.get(f)!.add(session.id); + } + + if (obs.type === "error" && obs.title) { + const key = obs.title.toLowerCase(); + if (!errorPatterns.has(key)) { + errorPatterns.set(key, { count: 0, sessions: new Set() }); + } + const ep = errorPatterns.get(key)!; + ep.count++; + ep.sessions.add(session.id); + } + } + + const fileList = [...sessionFiles].sort(); + for (let i = 0; i < fileList.length; i++) { + for (let j = i + 1; j < fileList.length; j++) { + const pair = `${fileList[i]}::${fileList[j]}`; + fileCoOccurrences.set(pair, (fileCoOccurrences.get(pair) || 0) + 1); + } + } + } + + for (const [pair, count] of fileCoOccurrences) { + if (count < 3) continue; + const [fileA, fileB] = pair.split("::"); + const sessionsA = fileSessionMap.get(fileA) || new Set(); + const sessionsB = fileSessionMap.get(fileB) || new Set(); + const commonSessions = [...sessionsA].filter((s) => sessionsB.has(s)); + + patterns.push({ + type: "co_change", + description: `${fileA} and ${fileB} are frequently modified together`, + files: [fileA, fileB], + frequency: count, + sessions: commonSessions, + }); + } + + for (const [ + errorKey, + { count, sessions: errorSessions }, + ] of errorPatterns) { + if (count < 2) continue; + patterns.push({ + type: "error_repeat", + description: `Recurring error: ${errorKey}`, + files: [], + frequency: count, + sessions: [...errorSessions], + }); + } + + patterns.sort((a, b) => b.frequency - a.frequency); + + logger.info("Pattern detection complete", { + patterns: patterns.length, + sessions: filtered.length, + }); + + return { patterns: patterns.slice(0, 20) }; + }, + ); + + sdk.registerFunction("mem::generate-rules", + async (data: { project?: string }) => { + const result = await sdk.trigger< + { project?: string }, + { patterns: Pattern[] } + >({ function_id: "mem::patterns", payload: data }); + + const rules: string[] = []; + + for (const pattern of result.patterns) { + if (pattern.type === "co_change" && pattern.frequency >= 4) { + rules.push( + `When modifying ${pattern.files[0]}, also check ${pattern.files[1]} (co-changed ${pattern.frequency} times).`, + ); + } + if (pattern.type === "error_repeat" && pattern.frequency >= 3) { + rules.push( + `Watch for: ${pattern.description} (occurred ${pattern.frequency} times across ${pattern.sessions.length} sessions).`, + ); + } + } + + logger.info("Rules generated", { count: rules.length }); + return { rules }; + }, + ); +} diff --git a/src/functions/privacy.ts b/src/functions/privacy.ts new file mode 100644 index 0000000..f28edd3 --- /dev/null +++ b/src/functions/privacy.ts @@ -0,0 +1,40 @@ +import type { ISdk } from "iii-sdk"; + +const PRIVATE_TAG_RE = /[\s\S]*?<\/private>/gi; + +const SECRET_PATTERN_SOURCES = [ + /(?:api[_-]?key|secret|token|password|credential|auth)[\s]*[=:]\s*["']?[A-Za-z0-9_\-/.+]{20,}["']?/gi, + /Bearer\s+[A-Za-z0-9._\-+/=]{20,}/gi, + /sk-proj-[A-Za-z0-9\-_]{20,}/g, + /(?:sk|pk|rk|ak)-[A-Za-z0-9][A-Za-z0-9\-_]{19,}/g, + /sk-ant-[A-Za-z0-9\-_]{20,}/g, + /gh[pus]_[A-Za-z0-9]{36,}/g, + /github_pat_[A-Za-z0-9_]{22,}/g, + /xoxb-[A-Za-z0-9\-]+/g, + /AKIA[0-9A-Z]{16}/g, + /AIza[A-Za-z0-9\-_]{35}/g, + /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, + /npm_[A-Za-z0-9]{36}/g, + /glpat-[A-Za-z0-9\-_]{20,}/g, + /dop_v1_[A-Za-z0-9]{64}/g, +]; + +export function stripPrivateData(input: string): string { + let result = input.replace(PRIVATE_TAG_RE, "[REDACTED]"); + for (const source of SECRET_PATTERN_SOURCES) { + const pattern = new RegExp(source.source, source.flags); + result = result.replace(pattern, "[REDACTED_SECRET]"); + } + return result; +} + +export function registerPrivacyFunction(sdk: ISdk): void { + sdk.registerFunction("mem::privacy", + async (data: { input?: unknown } | undefined) => { + if (!data || typeof data.input !== "string") { + return { output: "", error: "invalid input: expected string field 'input'" }; + } + return { output: stripPrivateData(data.input) }; + }, + ); +} diff --git a/src/functions/profile.ts b/src/functions/profile.ts new file mode 100644 index 0000000..07881e9 --- /dev/null +++ b/src/functions/profile.ts @@ -0,0 +1,159 @@ +import type { ISdk } from "iii-sdk"; +import type { + CompressedObservation, + Session, + ProjectProfile, +} from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +export function registerProfileFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::profile", + async (data: { project: string; refresh?: boolean } | undefined) => { + if (!data || typeof data.project !== "string" || !data.project.trim()) { + return { success: false, error: "project is required" }; + } + const project = data.project.trim(); + + if (!data.refresh) { + const cached = await kv + .get(KV.profiles, project) + .catch(() => null); + if (cached) { + const age = Date.now() - new Date(cached.updatedAt).getTime(); + if (age < 3600_000) { + return { profile: cached, cached: true }; + } + } + } + + const sessions = await kv.list(KV.sessions); + const projectSessions = sessions.filter( + (s) => s.project === project, + ); + + if (projectSessions.length === 0) { + return { profile: null, reason: "no_sessions" }; + } + + const conceptFreq = new Map(); + const fileFreq = new Map(); + const errors: string[] = []; + const recentActivity: string[] = []; + let totalObs = 0; + + const sortedSessions = projectSessions.sort( + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ); + + const top20Sessions = sortedSessions.slice(0, 20); + const obsPerSession = await Promise.all( + top20Sessions.map((s) => + kv + .list(KV.observations(s.id)) + .catch(() => [] as CompressedObservation[]), + ), + ); + + for (let i = 0; i < top20Sessions.length; i++) { + const session = top20Sessions[i]; + const observations = obsPerSession[i]; + totalObs += observations.length; + + for (const obs of observations) { + for (const concept of obs.concepts || []) { + conceptFreq.set(concept, (conceptFreq.get(concept) || 0) + 1); + } + for (const file of obs.files || []) { + fileFreq.set(file, (fileFreq.get(file) || 0) + 1); + } + if (obs.type === "error") { + errors.push(obs.title); + } + } + + const important = observations + .filter((o) => o.importance >= 7) + .sort((a, b) => b.importance - a.importance); + if (important.length > 0) { + recentActivity.push( + `[${session.startedAt.slice(0, 10)}] ${important[0].title}`, + ); + } + } + + const topConcepts = Array.from(conceptFreq.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([concept, frequency]) => ({ concept, frequency })); + + const topFiles = Array.from(fileFreq.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 15) + .map(([file, frequency]) => ({ file, frequency })); + + const uniqueErrors = [...new Set(errors)].slice(0, 10); + + const profile: ProjectProfile = { + project, + updatedAt: new Date().toISOString(), + topConcepts, + topFiles, + conventions: extractConventions(topConcepts, topFiles), + commonErrors: uniqueErrors, + recentActivity: recentActivity.slice(0, 10), + sessionCount: projectSessions.length, + totalObservations: totalObs, + }; + + await kv.set(KV.profiles, project, profile); + await recordAudit(kv, "share", "mem::profile", [project], { + sessionCount: projectSessions.length, + totalObservations: totalObs, + }); + + logger.info("Profile generated", { + project, + sessions: projectSessions.length, + observations: totalObs, + }); + return { profile, cached: false }; + }, + ); +} + +function extractConventions( + concepts: Array<{ concept: string; frequency: number }>, + files: Array<{ file: string; frequency: number }>, +): string[] { + const conventions: string[] = []; + + const tsFiles = files.filter((f) => f.file.endsWith(".ts")).length; + const jsFiles = files.filter((f) => f.file.endsWith(".js")).length; + if (tsFiles > jsFiles && tsFiles > 0) { + conventions.push("TypeScript project"); + } + + const srcFiles = files.filter((f) => f.file.includes("/src/")).length; + if (srcFiles > files.length * 0.5) { + conventions.push("Standard src/ directory structure"); + } + + const testFiles = files.filter( + (f) => f.file.includes("test") || f.file.includes("spec"), + ).length; + if (testFiles > 0) { + conventions.push("Has test files"); + } + + for (const { concept, frequency } of concepts.slice(0, 5)) { + if (frequency >= 3) { + conventions.push(`Frequently uses: ${concept}`); + } + } + + return conventions; +} diff --git a/src/functions/query-expansion.ts b/src/functions/query-expansion.ts new file mode 100644 index 0000000..00cfdf4 --- /dev/null +++ b/src/functions/query-expansion.ts @@ -0,0 +1,188 @@ +import type { ISdk } from "iii-sdk"; +import type { MemoryProvider, QueryExpansion } from "../types.js"; +import { logger } from "../logger.js"; + +const QUERY_EXPANSION_SYSTEM = `You are a query expansion engine for a memory retrieval system. Given a user query, generate diverse reformulations to maximize recall. + +Output EXACTLY this XML: + + + semantically diverse rephrasing 1 + semantically diverse rephrasing 2 + semantically diverse rephrasing 3 + + + time-concretized version if applicable + + + extracted entity name 1 + extracted entity name 2 + + + +Rules: +- Generate 3-5 reformulations capturing different interpretations +- Include paraphrases, domain-specific restatements, and abstract/concrete variants +- Extract any named entities (people, files, projects, libraries, concepts) +- If the query mentions time ("last week", "recently"), generate temporal concretizations +- Each reformulation should capture a distinct facet of intent +- Keep reformulations concise (under 100 chars each)`; + +function parseExpansionXml(xml: string): QueryExpansion | null { + const reformulations: string[] = []; + const queryRegex = + /[\s\S]*?<\/reformulations>/; + const reformBlock = xml.match(queryRegex); + if (reformBlock) { + const qRegex = /([^<]+)<\/query>/g; + let match; + while ((match = qRegex.exec(reformBlock[0])) !== null) { + reformulations.push(match[1].trim()); + } + } + + const temporalConcretizations: string[] = []; + const tempBlock = xml.match(/[\s\S]*?<\/temporal>/); + if (tempBlock) { + const qRegex = /([^<]+)<\/query>/g; + let match; + while ((match = qRegex.exec(tempBlock[0])) !== null) { + temporalConcretizations.push(match[1].trim()); + } + } + + const entityExtractions: string[] = []; + const entityRegex = /([^<]+)<\/entity>/g; + let match; + while ((match = entityRegex.exec(xml)) !== null) { + entityExtractions.push(match[1].trim()); + } + + return { + original: "", + reformulations, + temporalConcretizations, + entityExtractions, + }; +} + +export function registerQueryExpansionFunction( + sdk: ISdk, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::expand-query", + async (data: { query: string; maxReformulations?: number } | undefined) => { + if (!data || typeof data.query !== "string" || !data.query.trim()) { + logger.warn("Invalid expand-query payload"); + return { success: false, error: "query must be a non-empty string" }; + } + const rawMaxR = Number(data.maxReformulations); + const maxR = Number.isFinite(rawMaxR) + ? Math.max(1, Math.min(10, Math.floor(rawMaxR))) + : 5; + const query = data.query.trim(); + + try { + const response = await provider.compress( + QUERY_EXPANSION_SYSTEM, + `Expand this query for memory retrieval:\n\n"${query}"`, + ); + + const parsed = parseExpansionXml(response); + if (!parsed) { + logger.warn("Failed to parse query expansion"); + return { + success: true, + expansion: { + original: query, + reformulations: [], + temporalConcretizations: [], + entityExtractions: [], + }, + }; + } + + parsed.original = query; + parsed.reformulations = parsed.reformulations.slice(0, maxR); + + logger.info("Query expanded", { + original: query, + reformulations: parsed.reformulations.length, + entities: parsed.entityExtractions.length, + }); + + return { success: true, expansion: parsed }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Query expansion failed", { error: msg }); + return { + success: true, + expansion: { + original: query, + reformulations: [], + temporalConcretizations: [], + entityExtractions: [], + }, + }; + } + }, + ); +} + +export function extractEntitiesFromQuery(query: string): string[] { + const entities: string[] = []; + const quoted = query.match(/"([^"]+)"/g); + if (quoted) { + for (const q of quoted) { + entities.push(q.replace(/"/g, "")); + } + } + const capitalized = query.match(/\b[A-Z][a-zA-Z0-9_.-]+\b/g); + if (capitalized) { + const stopWords = new Set([ + "The", + "This", + "That", + "What", + "When", + "Where", + "How", + "Why", + "Who", + "Which", + "Did", + "Does", + "Do", + "Is", + "Are", + "Was", + "Were", + "Has", + "Have", + "Had", + "Can", + "Could", + "Would", + "Should", + "Will", + "May", + "Might", + "If", + "And", + "But", + "Or", + "Not", + "For", + "From", + "With", + "About", + "After", + "Before", + "Between", + ]); + for (const c of capitalized) { + if (!stopWords.has(c)) entities.push(c); + } + } + return [...new Set(entities)]; +} diff --git a/src/functions/recent-searches-sweep.ts b/src/functions/recent-searches-sweep.ts new file mode 100644 index 0000000..03df192 --- /dev/null +++ b/src/functions/recent-searches-sweep.ts @@ -0,0 +1,72 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import { logger } from "../logger.js"; +import { getFollowupStats, type RecentSearch } from "./smart-search.js"; +import { getFollowupWindowSeconds } from "../config.js"; + +// #771: TTL sweep for the followup-rate diagnostic scope. `recentSearches` +// only needs the most recent entry per session, but stale rows accumulate +// when sessions go idle. Hourly sweep deletes rows whose last update is +// older than the retention window. +const RETENTION_MS = 24 * 60 * 60 * 1000; + +export function registerRecentSearchesSweepFunction( + sdk: ISdk, + kv: StateKV, +): void { + sdk.registerFunction( + "mem::diagnostic::recent-searches-sweep", + async (): Promise<{ success: true; swept: number; skipped: number }> => { + const cutoff = Date.now() - RETENTION_MS; + const rows = await kv + .list>(KV.recentSearches) + .catch(() => []); + let swept = 0; + let skipped = 0; + for (const row of rows) { + if (!row || typeof row.sessionId !== "string" || !row.sessionId) { + skipped++; + continue; + } + const at = typeof row.at === "number" ? row.at : 0; + if (at >= cutoff) continue; + try { + await kv.delete(KV.recentSearches, row.sessionId); + swept++; + } catch (err) { + logger.warn("recent-searches sweep delete failed", { + sessionId: row.sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + if (swept > 0 || skipped > 0) { + logger.info("Recent-searches sweep complete", { swept, skipped }); + } + return { success: true, swept, skipped }; + }, + ); + + // #771: read-back surface for `agentmemory status` and external + // dashboards that don't go through the OTEL collector. + sdk.registerFunction( + "mem::diagnostic::followup-stats", + async (): Promise<{ + success: true; + windowSeconds: number; + agentInitiatedSearches: number; + followupWithinWindow: number; + rate: number; + }> => { + const stats = getFollowupStats(); + return { + success: true, + windowSeconds: getFollowupWindowSeconds(), + agentInitiatedSearches: stats.agentInitiatedSearches, + followupWithinWindow: stats.followupWithinWindow, + rate: stats.rate, + }; + }, + ); +} diff --git a/src/functions/reflect.ts b/src/functions/reflect.ts new file mode 100644 index 0000000..3a2af82 --- /dev/null +++ b/src/functions/reflect.ts @@ -0,0 +1,477 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, fingerprintId } from "../state/schema.js"; +import type { + Insight, + GraphNode, + GraphEdge, + SemanticMemory, + Lesson, + Crystal, + MemoryProvider, +} from "../types.js"; +import { recordAudit } from "./audit.js"; +import { REFLECT_SYSTEM, buildReflectPrompt } from "../prompts/reflect.js"; + +interface ConceptCluster { + concepts: string[]; + facts: Array<{ fact: string; confidence: number }>; + lessons: Array<{ content: string; confidence: number }>; + crystalNarratives: string[]; + factIds: string[]; + lessonIds: string[]; + crystalIds: string[]; +} + +function reinforceInsight(insight: Insight): void { + const now = new Date().toISOString(); + insight.reinforcements++; + insight.confidence = Math.min( + 1.0, + insight.confidence + 0.1 * (1 - insight.confidence), + ); + insight.lastReinforcedAt = now; + insight.updatedAt = now; +} + +function buildGraphClusters( + nodes: GraphNode[], + edges: GraphEdge[], + maxClusters: number, +): string[][] { + const conceptNodes = nodes.filter( + (n) => n.type === "concept" && !n.stale, + ); + if (conceptNodes.length === 0) return []; + + const edgeMap = new Map>(); + for (const edge of edges) { + if (edge.stale) continue; + if (!edgeMap.has(edge.sourceNodeId)) + edgeMap.set(edge.sourceNodeId, new Set()); + if (!edgeMap.has(edge.targetNodeId)) + edgeMap.set(edge.targetNodeId, new Set()); + edgeMap.get(edge.sourceNodeId)!.add(edge.targetNodeId); + edgeMap.get(edge.targetNodeId)!.add(edge.sourceNodeId); + } + + const degree = new Map(); + for (const node of conceptNodes) { + degree.set(node.id, edgeMap.get(node.id)?.size || 0); + } + + const sorted = [...conceptNodes].sort( + (a, b) => (degree.get(b.id) || 0) - (degree.get(a.id) || 0), + ); + + const visited = new Set(); + const clusters: string[][] = []; + const conceptNodeIds = new Set(conceptNodes.map((n) => n.id)); + + for (const seed of sorted) { + if (visited.has(seed.id) || clusters.length >= maxClusters) break; + + const cluster: string[] = []; + const queue = [seed.id]; + const seen = new Set(); + let depth = 0; + + while (queue.length > 0 && depth <= 2) { + const levelCount = queue.length; + for (let i = 0; i < levelCount; i++) { + const current = queue.shift()!; + if (seen.has(current)) continue; + seen.add(current); + + if (conceptNodeIds.has(current)) { + const node = conceptNodes.find((n) => n.id === current); + if (node) cluster.push(node.name); + visited.add(current); + } + + const neighbors = edgeMap.get(current) || new Set(); + for (const neighbor of neighbors) { + if (!seen.has(neighbor)) queue.push(neighbor); + } + } + depth++; + } + + if (cluster.length >= 2) clusters.push(cluster); + } + + return clusters; +} + +function buildJaccardClusters( + semanticMemories: SemanticMemory[], + lessons: Lesson[], + maxClusters: number, +): string[][] { + const allConcepts = new Map>(); + + for (const sem of semanticMemories) { + const terms = sem.fact.toLowerCase().split(/\s+/).filter((t) => t.length > 3); + for (const term of terms) { + if (!allConcepts.has(term)) allConcepts.set(term, new Set()); + allConcepts.get(term)!.add(sem.id); + } + } + for (const lesson of lessons) { + for (const tag of lesson.tags) { + const key = tag.toLowerCase(); + if (!allConcepts.has(key)) allConcepts.set(key, new Set()); + allConcepts.get(key)!.add(lesson.id); + } + } + + const conceptList = [...allConcepts.keys()].filter( + (k) => (allConcepts.get(k)?.size || 0) >= 2, + ); + + const visited = new Set(); + const clusters: string[][] = []; + + for (const concept of conceptList) { + if (visited.has(concept) || clusters.length >= maxClusters) break; + + const cluster = [concept]; + visited.add(concept); + + const docsA = allConcepts.get(concept) || new Set(); + for (const other of conceptList) { + if (visited.has(other)) continue; + const docsB = allConcepts.get(other) || new Set(); + let intersection = 0; + for (const d of docsA) { + if (docsB.has(d)) intersection++; + } + const union = docsA.size + docsB.size - intersection; + const similarity = union > 0 ? intersection / union : 0; + if (similarity > 0.3) { + cluster.push(other); + visited.add(other); + } + } + + if (cluster.length >= 2) clusters.push(cluster); + } + + return clusters; +} + +export function registerReflectFunctions( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::reflect", + async (data: { maxClusters?: number; project?: string }) => { + const maxClusters = Math.min(data?.maxClusters ?? 10, 20); + const maxInsightsPerCluster = 5; + const maxTotal = 50; + + const [graphNodes, graphEdges, semanticMemories, lessons, crystals] = + await Promise.all([ + kv.list(KV.graphNodes).catch(() => []), + kv.list(KV.graphEdges).catch(() => []), + kv.list(KV.semantic).catch(() => []), + kv.list(KV.lessons).catch(() => []), + kv.list(KV.crystals).catch(() => []), + ]); + + let activeLessons = lessons.filter((l) => !l.deleted); + if (data?.project) { + activeLessons = activeLessons.filter((l) => l.project === data.project); + } + + let conceptClusters = buildGraphClusters( + graphNodes, + graphEdges, + maxClusters, + ); + + const usedFallback = conceptClusters.length === 0; + if (usedFallback) { + conceptClusters = buildJaccardClusters( + semanticMemories, + activeLessons, + maxClusters, + ); + } + + let newInsights = 0; + let reinforced = 0; + let clustersSkipped = 0; + let totalInsights = 0; + + for (const conceptNames of conceptClusters) { + if (totalInsights >= maxTotal) break; + + const conceptSet = new Set(conceptNames.map((c) => c.toLowerCase())); + + const clusterFacts = semanticMemories.filter((s) => { + const factTerms = s.fact.toLowerCase().split(/\s+/); + return factTerms.some((t) => conceptSet.has(t)); + }); + + const clusterLessons = activeLessons.filter((l) => + l.tags.some((t) => conceptSet.has(t.toLowerCase())) || + conceptNames.some((c) => + l.content.toLowerCase().includes(c.toLowerCase()), + ), + ); + + const clusterCrystals = crystals.filter((c) => + (c.lessons || []).some((l) => + conceptNames.some((cn) => + l.toLowerCase().includes(cn.toLowerCase()), + ), + ), + ); + + const totalItems = + clusterFacts.length + clusterLessons.length + clusterCrystals.length; + if (totalItems < 3) { + clustersSkipped++; + continue; + } + + const cluster: ConceptCluster = { + concepts: conceptNames, + facts: clusterFacts.map((f) => ({ + fact: f.fact, + confidence: f.confidence, + })), + lessons: clusterLessons.map((l) => ({ + content: l.content, + confidence: l.confidence, + })), + crystalNarratives: clusterCrystals.map((c) => c.narrative), + factIds: clusterFacts.map((f) => f.id), + lessonIds: clusterLessons.map((l) => l.id), + crystalIds: clusterCrystals.map((c) => c.id), + }; + + try { + const prompt = buildReflectPrompt(cluster); + const response = await provider.summarize(REFLECT_SYSTEM, prompt); + + const insightRegex = + /([\s\S]*?)<\/insight>/g; + let match; + let clusterCount = 0; + + while ( + (match = insightRegex.exec(response)) !== null && + clusterCount < maxInsightsPerCluster && + totalInsights < maxTotal + ) { + const parsedConf = parseFloat(match[1]); + const confidence = Number.isNaN(parsedConf) + ? 0.5 + : Math.max(0, Math.min(1, parsedConf)); + const title = match[2].trim(); + const content = match[3].trim(); + + if (!content) continue; + + const fp = fingerprintId("ins", content.trim().toLowerCase()); + const existing = await kv.get(KV.insights, fp); + + if (existing && !existing.deleted) { + reinforceInsight(existing); + await kv.set(KV.insights, existing.id, existing); + reinforced++; + } else { + const now = new Date().toISOString(); + const insight: Insight = { + id: fp, + title, + content, + confidence, + reinforcements: 0, + sourceConceptCluster: conceptNames, + sourceMemoryIds: cluster.factIds, + sourceLessonIds: cluster.lessonIds, + sourceCrystalIds: cluster.crystalIds, + project: data?.project, + tags: conceptNames, + createdAt: now, + updatedAt: now, + decayRate: 0.05, + }; + await kv.set(KV.insights, insight.id, insight); + newInsights++; + } + + clusterCount++; + totalInsights++; + } + } catch { + continue; + } + } + + try { + await recordAudit(kv, "reflect", "mem::reflect", [], { + newInsights, + reinforced, + clustersProcessed: conceptClusters.length - clustersSkipped, + clustersSkipped, + usedFallback, + }); + } catch {} + + return { + success: true, + newInsights, + reinforced, + clustersProcessed: conceptClusters.length - clustersSkipped, + clustersSkipped, + usedFallback, + }; + }, + ); + + sdk.registerFunction("mem::insight-list", + async (data: { + project?: string; + minConfidence?: number; + limit?: number; + }) => { + const limit = data?.limit ?? 50; + const minConfidence = data?.minConfidence ?? 0; + let items = await kv.list(KV.insights); + + items = items.filter( + (i) => !i.deleted && i.confidence >= minConfidence, + ); + + if (data?.project) { + items = items.filter((i) => i.project === data.project); + } + + items.sort((a, b) => b.confidence - a.confidence); + + return { success: true, insights: items.slice(0, limit) }; + }, + ); + + sdk.registerFunction("mem::insight-search", + async (data: { + query: string; + project?: string; + minConfidence?: number; + limit?: number; + }) => { + if (!data?.query?.trim()) { + return { success: false, error: "query is required" }; + } + + const query = data.query.toLowerCase(); + const minConfidence = data.minConfidence ?? 0.1; + const limit = data.limit ?? 10; + + let items = await kv.list(KV.insights); + items = items.filter( + (i) => !i.deleted && i.confidence >= minConfidence, + ); + + if (data.project) { + items = items.filter((i) => i.project === data.project); + } + + const terms = query.split(/\s+/).filter((t) => t.length > 1); + const scored = items + .map((i) => { + const text = + `${i.title} ${i.content} ${i.tags.join(" ")}`.toLowerCase(); + const matchCount = terms.filter((t) => text.includes(t)).length; + if (matchCount === 0) return null; + + const relevance = matchCount / terms.length; + const daysSince = i.lastReinforcedAt + ? (Date.now() - new Date(i.lastReinforcedAt).getTime()) / + (1000 * 60 * 60 * 24) + : (Date.now() - new Date(i.createdAt).getTime()) / + (1000 * 60 * 60 * 24); + const recencyBoost = 1 / (1 + daysSince * 0.01); + const score = i.confidence * relevance * recencyBoost; + + return { insight: i, score }; + }) + .filter(Boolean) as Array<{ insight: Insight; score: number }>; + + scored.sort((a, b) => b.score - a.score); + + try { + await recordAudit(kv, "insight_search", "mem::insight-search", [], { + query: data.query, + resultCount: scored.length, + }); + } catch {} + + return { + success: true, + insights: scored.slice(0, limit).map((s) => ({ + ...s.insight, + score: Math.round(s.score * 1000) / 1000, + })), + }; + }, + ); + + sdk.registerFunction("mem::insight-decay-sweep", + async () => { + const items = await kv.list(KV.insights); + let decayed = 0; + let softDeleted = 0; + const now = Date.now(); + const timestamp = new Date().toISOString(); + const dirty: Insight[] = []; + + for (const insight of items) { + if (insight.deleted) continue; + + const baseline = + insight.lastDecayedAt || + insight.lastReinforcedAt || + insight.createdAt; + const weeksSince = + (now - new Date(baseline).getTime()) / (1000 * 60 * 60 * 24 * 7); + + if (weeksSince < 1) continue; + + const decay = insight.decayRate * weeksSince; + const newConfidence = Math.max(0.05, insight.confidence - decay); + + if (newConfidence !== insight.confidence) { + insight.confidence = Math.round(newConfidence * 1000) / 1000; + insight.lastDecayedAt = timestamp; + insight.updatedAt = timestamp; + + if (insight.confidence <= 0.1 && insight.reinforcements === 0) { + insight.deleted = true; + softDeleted++; + } else { + decayed++; + } + + dirty.push(insight); + } + } + + await Promise.all(dirty.map((i) => kv.set(KV.insights, i.id, i))); + await recordAudit(kv, "reflect", "mem::insight-decay-sweep", dirty.map((i) => i.id), { + event: "insight.decay", + decayed, + softDeleted, + total: items.length, + timestamp, + }); + + return { success: true, decayed, softDeleted, total: items.length }; + }, + ); +} diff --git a/src/functions/relations.ts b/src/functions/relations.ts new file mode 100644 index 0000000..7921a32 --- /dev/null +++ b/src/functions/relations.ts @@ -0,0 +1,278 @@ +import type { ISdk } from "iii-sdk"; +import type { Memory, MemoryRelation } from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { safeAudit } from "./audit.js"; +import { recordAccessBatch } from "./access-tracker.js"; +import { logger } from "../logger.js"; + +function computeConfidence( + source: Memory, + target: Memory, + relationType: MemoryRelation["type"], +): number { + let score = 0.5; + + const sharedSessions = source.sessionIds.filter((sid) => + target.sessionIds.includes(sid), + ); + score += Math.min(sharedSessions.length * 0.1, 0.3); + + const now = Date.now(); + const sourceAge = now - new Date(source.updatedAt).getTime(); + const targetAge = now - new Date(target.updatedAt).getTime(); + const sevenDays = 7 * 24 * 60 * 60 * 1000; + const ninetyDays = 90 * 24 * 60 * 60 * 1000; + if (sourceAge < sevenDays && targetAge < sevenDays) { + score += 0.1; + } else if (sourceAge > ninetyDays && targetAge > ninetyDays) { + score -= 0.1; + } + + if (relationType === "supersedes") score += 0.1; + if (relationType === "contradicts") score -= 0.05; + + return Math.max(0, Math.min(1, score)); +} + +export function registerRelationsFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::relate", + async (data: { + sourceId: string; + targetId: string; + type: MemoryRelation["type"]; + confidence?: number; + }) => { + const [firstId, secondId] = [data.sourceId, data.targetId].sort(); + const lockKey = + firstId === secondId ? `mem:${firstId}` : `mem:${firstId}:${secondId}`; + + return withKeyedLock(lockKey, async () => { + const source = await kv.get(KV.memories, data.sourceId); + const target = await kv.get(KV.memories, data.targetId); + if (!source || !target) { + return { + success: false, + error: "source or target memory not found", + }; + } + + const confidence = + data.confidence !== undefined + ? Math.max(0, Math.min(1, data.confidence)) + : computeConfidence(source, target, data.type); + + const relation: MemoryRelation = { + type: data.type, + sourceId: data.sourceId, + targetId: data.targetId, + createdAt: new Date().toISOString(), + confidence, + }; + + const relationId = generateId("rel"); + await kv.set(KV.relations, relationId, relation); + + if (!source.relatedIds) source.relatedIds = []; + let sourceUpdated = false; + if (!source.relatedIds.includes(data.targetId)) { + source.relatedIds.push(data.targetId); + await kv.set(KV.memories, data.sourceId, source); + sourceUpdated = true; + } + + if (!target.relatedIds) target.relatedIds = []; + let targetUpdated = false; + if (!target.relatedIds.includes(data.sourceId)) { + target.relatedIds.push(data.sourceId); + await kv.set(KV.memories, data.targetId, target); + targetUpdated = true; + } + + await safeAudit(kv, "relation_create", "mem::relate", [relationId], { + type: data.type, + sourceId: data.sourceId, + targetId: data.targetId, + confidence, + }); + if (sourceUpdated) { + await safeAudit( + kv, + "relation_update", + "mem::relate", + [data.sourceId], + { relationId, updatedRelatedId: data.targetId }, + ); + } + if (targetUpdated) { + await safeAudit( + kv, + "relation_update", + "mem::relate", + [data.targetId], + { relationId, updatedRelatedId: data.sourceId }, + ); + } + + logger.info("Memory relation created", { + relationId, + type: data.type, + source: data.sourceId, + target: data.targetId, + }); + return { success: true, relationId, relation }; + }); + }, + ); + + sdk.registerFunction("mem::evolve", + async (data: { + memoryId: string; + newContent: string; + newTitle?: string; + }) => { + + const existing = await kv.get(KV.memories, data.memoryId); + if (!existing) { + return { success: false, error: "memory not found" }; + } + + const now = new Date().toISOString(); + const evolved: Memory = { + ...existing, + id: generateId("mem"), + createdAt: now, + updatedAt: now, + title: data.newTitle || existing.title, + content: data.newContent, + version: (existing.version || 1) + 1, + parentId: existing.id, + supersedes: [existing.id, ...(existing.supersedes || [])], + isLatest: true, + }; + + existing.isLatest = false; + await kv.set(KV.memories, existing.id, existing); + await safeAudit(kv, "evolve", "mem::evolve", [existing.id], { + operation: "evolve", + action: "mark_non_latest", + newId: evolved.id, + }); + + await kv.set(KV.memories, evolved.id, evolved); + await safeAudit(kv, "evolve", "mem::evolve", [evolved.id], { + operation: "evolve", + oldId: existing.id, + newId: evolved.id, + version: evolved.version, + }); + + const relation: MemoryRelation = { + type: "supersedes", + sourceId: evolved.id, + targetId: existing.id, + createdAt: now, + confidence: 1.0, + }; + const relationId = generateId("rel"); + await kv.set(KV.relations, relationId, relation); + await safeAudit(kv, "evolve", "mem::evolve", [relationId], { + operation: "supersedes", + oldId: existing.id, + newId: evolved.id, + }); + + logger.info("Memory evolved", { + oldId: existing.id, + newId: evolved.id, + version: evolved.version, + }); + return { success: true, memory: evolved, previousId: existing.id }; + }, + ); + + sdk.registerFunction("mem::get-related", + async (data: { + memoryId: string; + maxHops?: number; + minConfidence?: number; + }) => { + const maxHops = Math.min(data.maxHops ?? 2, 5); + const MAX_VISITED = 500; + const rawMinConf = Number(data.minConfidence); + const minConfidence = Number.isFinite(rawMinConf) + ? Math.max(0, Math.min(1, rawMinConf)) + : 0; + + const allRelations = await kv + .list(KV.relations) + .catch(() => []); + + const visited = new Set(); + const result: Array<{ + memory: Memory; + hop: number; + confidence: number; + }> = []; + const queue: Array<{ id: string; hop: number }> = [ + { id: data.memoryId, hop: 0 }, + ]; + + while (queue.length > 0 && visited.size < MAX_VISITED) { + const current = queue.shift()!; + if (visited.has(current.id) || current.hop > maxHops) continue; + visited.add(current.id); + + const memory = await kv.get(KV.memories, current.id); + if (!memory) continue; + + if (current.hop > 0) { + const matchingRelations = allRelations.filter( + (r) => + (r.sourceId === current.id && visited.has(r.targetId)) || + (r.targetId === current.id && visited.has(r.sourceId)), + ); + const confidence = + matchingRelations.length > 0 + ? Math.max(...matchingRelations.map((r) => r.confidence ?? 0.5)) + : 0.5; + if (confidence >= minConfidence) { + result.push({ memory, hop: current.hop, confidence }); + } + } + + const relatedIds = memory.relatedIds || []; + const supersedes = memory.supersedes || []; + const parentId = memory.parentId ? [memory.parentId] : []; + + const kvLinked = allRelations + .filter((r) => r.sourceId === current.id || r.targetId === current.id) + .map((r) => (r.sourceId === current.id ? r.targetId : r.sourceId)); + + const allLinks = [ + ...new Set([...relatedIds, ...supersedes, ...parentId, ...kvLinked]), + ]; + + for (const nextId of allLinks) { + if (!visited.has(nextId)) { + queue.push({ id: nextId, hop: current.hop + 1 }); + } + } + } + + result.sort((a, b) => b.confidence - a.confidence); + + void recordAccessBatch( + kv, + result.map((r) => r.memory.id), + ); + + logger.info("Related memories retrieved", { + memoryId: data.memoryId, + found: result.length, + }); + return { results: result }; + }, + ); +} diff --git a/src/functions/remember.ts b/src/functions/remember.ts new file mode 100644 index 0000000..5735b4f --- /dev/null +++ b/src/functions/remember.ts @@ -0,0 +1,264 @@ +import { TriggerAction, type ISdk } from "iii-sdk"; +import type { Memory } from "../types.js"; +import { KV, generateId, jaccardSimilarity } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +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 { getAgentId } from "../config.js"; +import { logger } from "../logger.js"; + +export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::remember", + async (data: { + content: string; + type?: string; + concepts?: string[]; + files?: string[]; + ttlDays?: number; + sourceObservationIds?: string[]; + agentId?: string; + project?: string; + }) => { + if ( + !data.content || + typeof data.content !== "string" || + !data.content.trim() + ) { + return { success: false, error: "content is required" }; + } + if (data.files && !Array.isArray(data.files)) { + return { success: false, error: "files must be an array" }; + } + if (data.concepts && !Array.isArray(data.concepts)) { + return { success: false, error: "concepts must be an array" }; + } + if (data.sourceObservationIds && !Array.isArray(data.sourceObservationIds)) { + return { success: false, error: "sourceObservationIds must be an array" }; + } + const validTypes = new Set([ + "pattern", + "preference", + "architecture", + "bug", + "workflow", + "fact", + ]); + const memType = validTypes.has(data.type || "") + ? (data.type as Memory["type"]) + : "fact"; + + const now = new Date().toISOString(); + // Normalize project early so every subsequent comparison and storage + // operation uses the same cleaned value. Raw data.project must not be + // referenced below this point. + const project = + typeof data.project === "string" && data.project.trim().length > 0 + ? data.project.trim() + : undefined; + + return withKeyedLock("mem:remember", async () => { + const existingMemories = await kv.list(KV.memories); + let supersededId: string | undefined; + let supersededVersion = 1; + let supersededMemory: Memory | undefined; + const lowerContent = data.content.toLowerCase(); + for (const existing of existingMemories) { + 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; + // an unscoped memory (legacy, no project field) is treated as a + // wildcard so pre-existing data is not stranded. + if (project && existing.project && existing.project !== project) { + continue; + } + const similarity = jaccardSimilarity( + lowerContent, + existing.content.toLowerCase(), + ); + if (similarity > 0.7) { + supersededId = existing.id; + supersededVersion = existing.version ?? 1; + supersededMemory = existing; + break; + } + } + + // stamp the agent role on the memory so future recall can + // filter by agent. Request body wins (multi-agent runtimes + // explicitly tagging at write time), env AGENT_ID fallback, + // none → memory is unscoped (legacy behavior). + const callAgentId = + typeof data.agentId === "string" && data.agentId.trim().length > 0 + ? data.agentId.trim().slice(0, 128) + : getAgentId(); + + const memory: Memory = { + id: generateId("mem"), + createdAt: now, + updatedAt: now, + type: memType, + title: data.content.slice(0, 80), + content: data.content, + concepts: data.concepts || [], + files: data.files || [], + sessionIds: [], + strength: 7, + version: supersededId ? supersededVersion + 1 : 1, + parentId: supersededId, + supersedes: supersededId ? [supersededId] : [], + sourceObservationIds: (data.sourceObservationIds || []).filter( + (id): id is string => typeof id === "string" && id.length > 0, + ), + isLatest: true, + ...(callAgentId ? { agentId: callAgentId } : {}), + ...(project !== undefined && { project }), + }; + + if (data.ttlDays && typeof data.ttlDays === "number" && data.ttlDays > 0) { + memory.forgetAfter = new Date(Date.now() + data.ttlDays * 86400000).toISOString(); + } + + if (supersededMemory) { + supersededMemory.isLatest = false; + await kv.set(KV.memories, supersededMemory.id, supersededMemory); + } + await kv.set(KV.memories, memory.id, memory); + + // Without this, mem::remember persists the row but the BM25 + // index never sees it, so memory_smart_search and memory_recall + // return empty even seconds after save (#257). Use try/catch so + // an indexing failure doesn't block the save itself — the + // restart-time rebuild will pick the memory up either way. + try { + getSearchIndex().add(memoryToObservation(memory)); + } catch (err) { + logger.warn("Failed to index saved memory into BM25", { + memId: memory.id, + error: err instanceof Error ? err.message : String(err), + }); + } + await vectorIndexAddGuarded( + memory.id, + memory.sessionIds?.[0] ?? "memory", + memory.title + " " + memory.content, + { kind: "memory", logId: memory.id }, + ); + + if (supersededId) { + await sdk.trigger({ + function_id: "mem::cascade-update", + payload: { + supersededMemoryId: supersededId, + }, + action: TriggerAction.Void(), + }); + } + + logger.info("Memory saved", { + memId: memory.id, + type: memory.type, + project: memory.project, + }); + return { success: true, memory }; + }); + }, + ); + + sdk.registerFunction("mem::forget", + async (data: { + sessionId?: string; + observationIds?: string[]; + memoryId?: string; + }) => { + let deleted = 0; + const deletedMemoryIds: string[] = []; + const deletedObservationIds: string[] = []; + let deletedSession = false; + const { decrementImageRef } = await import("./image-refs.js"); + + if (data.memoryId) { + const mem = await kv.get(KV.memories, data.memoryId); + await kv.delete(KV.memories, data.memoryId); + if (mem?.imageRef) { + await decrementImageRef(kv, sdk, mem.imageRef); + } + await deleteAccessLog(kv, data.memoryId); + getSearchIndex().remove(data.memoryId); + vectorIndexRemove(data.memoryId); + deletedMemoryIds.push(data.memoryId); + deleted++; + } + + if ( + data.sessionId && + data.observationIds && + data.observationIds.length > 0 + ) { + for (const obsId of data.observationIds) { + const obs = await kv.get<{ imageData?: string; imageRef?: string }>( + KV.observations(data.sessionId), + obsId, + ); + await kv.delete(KV.observations(data.sessionId), obsId); + if (obs?.imageData) await decrementImageRef(kv, sdk, obs.imageData); + if (obs?.imageRef && obs.imageRef !== obs.imageData) { + await decrementImageRef(kv, sdk, obs.imageRef); + } + getSearchIndex().remove(obsId); + vectorIndexRemove(obsId); + deletedObservationIds.push(obsId); + deleted++; + } + } + + if ( + data.sessionId && + (!data.observationIds || data.observationIds.length === 0) && + !data.memoryId + ) { + const observations = await kv.list<{ id: string; imageData?: string; imageRef?: string }>( + KV.observations(data.sessionId), + ); + for (const obs of observations) { + await kv.delete(KV.observations(data.sessionId), obs.id); + if (obs.imageData) await decrementImageRef(kv, sdk, obs.imageData); + if (obs.imageRef && obs.imageRef !== obs.imageData) { + await decrementImageRef(kv, sdk, obs.imageRef); + } + getSearchIndex().remove(obs.id); + vectorIndexRemove(obs.id); + deletedObservationIds.push(obs.id); + deleted++; + } + await kv.delete(KV.sessions, data.sessionId); + await kv.delete(KV.summaries, data.sessionId); + deletedSession = true; + deleted += 2; + } + + if (deleted > 0) { + await flushIndexSave(); + await recordAudit( + kv, + "forget", + "mem::forget", + [...deletedMemoryIds, ...deletedObservationIds], + { + sessionId: data.sessionId, + deleted, + memoriesDeleted: deletedMemoryIds.length, + observationsDeleted: deletedObservationIds.length, + sessionDeleted: deletedSession, + reason: "user-initiated forget", + }, + ); + } + + logger.info("Memory forgotten", { deleted }); + return { success: true, deleted }; + }, + ); +} diff --git a/src/functions/replay.ts b/src/functions/replay.ts new file mode 100644 index 0000000..e44ca8c --- /dev/null +++ b/src/functions/replay.ts @@ -0,0 +1,478 @@ +import { homedir } from "node:os"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import type { ISdk } from "iii-sdk"; +import type { + CompressedObservation, + Crystal, + Lesson, + RawObservation, + Session, +} 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 { projectTimeline, type Timeline } from "../replay/timeline.js"; +import { safeAudit } from "./audit.js"; +import { buildSyntheticCompression } from "./compress-synthetic.js"; +import { getSearchIndex } from "./search.js"; +import { logger } from "../logger.js"; + +export const MAX_FILES_DEFAULT = 200; +export const MAX_FILES_UPPER_BOUND = 1000; + +const SENSITIVE_PATH_PATTERNS: RegExp[] = [ + /(^|[\\/_.-])secret([\\/_.-]|s?$)/i, + /(^|[\\/_.-])credentials?([\\/_.-]|$)/i, + /(^|[\\/_.-])private[_-]?key([\\/_.-]|$)/i, + /(^|[\\/])\.env(\.[\w-]+)?$/i, + /(^|[\\/_.-])id_rsa([\\/_.-]|$)/i, + /(^|[\\/])auth[_-]?token([\\/_.-]|$)/i, + /(^|[\\/])bearer[_-]?token([\\/_.-]|$)/i, + /(^|[\\/])access[_-]?token([\\/_.-]|$)/i, + /(^|[\\/])api[_-]?token([\\/_.-]|$)/i, +]; + +export function isSensitive(path: string): boolean { + return SENSITIVE_PATH_PATTERNS.some((re) => re.test(path)); +} + +async function isSymlink(path: string): Promise { + try { + const st = await lstat(path); + return st.isSymbolicLink(); + } catch { + return false; + } +} + +function rawFromCompressed(obs: CompressedObservation): RawObservation { + return { + id: obs.id, + sessionId: obs.sessionId, + timestamp: obs.timestamp, + hookType: "post_tool_use", + toolName: undefined, + toolInput: undefined, + toolOutput: undefined, + userPrompt: obs.type === "conversation" ? obs.narrative : undefined, + assistantResponse: undefined, + raw: { title: obs.title, narrative: obs.narrative, facts: obs.facts }, + }; +} + +const LESSON_PATTERNS: RegExp[] = [ + /\b(always|never|don'?t|do not|make sure|remember to|note:|caveat:|warning:)\b[^.\n]{10,200}[.!\n]/gi, + /\b(prefer|avoid)\s[^.\n]{10,200}[.!\n]/gi, +]; + +async function deriveCrystalAndLessons( + kv: StateKV, + sessionId: string, + project: string, + rawObs: RawObservation[], + compressed: CompressedObservation[], + firstPrompt: string | undefined, +): Promise { + if (rawObs.length === 0) return; + const createdAt = new Date().toISOString(); + + const files = new Set(); + const tools = new Set(); + for (const c of compressed) { + for (const f of c.files || []) files.add(f); + if (c.type && c.type !== "conversation" && c.title) tools.add(c.title); + } + + const assistantTexts: string[] = []; + const userPrompts: string[] = []; + for (const r of rawObs) { + if (typeof r.assistantResponse === "string" && r.assistantResponse.trim()) { + assistantTexts.push(r.assistantResponse); + } + if (typeof r.userPrompt === "string" && r.userPrompt.trim()) { + userPrompts.push(r.userPrompt); + } + } + + const lessonMatches = new Map(); + for (const text of assistantTexts.concat(userPrompts).slice(0, 200)) { + for (const pat of LESSON_PATTERNS) { + pat.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = pat.exec(text)) !== null && lessonMatches.size < 40) { + const snippet = m[0].replace(/\s+/g, " ").trim(); + if (snippet.length >= 20 && snippet.length <= 220) { + const key = snippet.toLowerCase(); + if (!lessonMatches.has(key)) lessonMatches.set(key, snippet); + } + } + } + } + + const lessonEntries = Array.from(lessonMatches.values()).slice(0, 20); + const lessonIds: string[] = []; + for (const content of lessonEntries) { + // Content-addressed ID so re-importing the same JSONL does not + // duplicate lessons. fingerprintId hashes the normalized content, + // giving a stable lesson_xxx for identical text. + const lessonId = fingerprintId("lesson", content.trim().toLowerCase()); + try { + const existing = await kv.get(KV.lessons, lessonId); + if (existing) { + const existingSources = existing.sourceIds || []; + const mergedSources = existingSources.includes(sessionId) + ? existingSources + : [...existingSources, sessionId]; + const existingTags = existing.tags || []; + const mergedTags = existingTags.includes("auto-import") + ? existingTags + : [...existingTags, "auto-import"]; + const merged: Lesson = { + ...existing, + sourceIds: mergedSources, + tags: mergedTags, + reinforcements: (existing.reinforcements || 0) + 1, + updatedAt: createdAt, + lastReinforcedAt: createdAt, + }; + await kv.set(KV.lessons, lessonId, merged); + } else { + const lesson: Lesson = { + id: lessonId, + content, + context: firstPrompt || project, + confidence: 0.4, + reinforcements: 0, + source: "consolidation", + sourceIds: [sessionId], + project, + tags: ["auto-import"], + createdAt, + updatedAt: createdAt, + decayRate: 0.05, + }; + await kv.set(KV.lessons, lessonId, lesson); + } + lessonIds.push(lessonId); + } catch {} + } + + // Content-addressed on sessionId so re-importing the same session + // upserts the crystal in place instead of creating a new one. + const crystalId = fingerprintId("crystal", sessionId); + const narrativePreview = firstPrompt + ? firstPrompt.slice(0, 300) + : compressed + .slice(0, 5) + .map((c) => c.narrative || c.title) + .filter(Boolean) + .join(" · ") + .slice(0, 300); + + try { + const existingCrystal = await kv.get(KV.crystals, crystalId); + const crystal: Crystal = { + id: crystalId, + narrative: narrativePreview || `Session ${sessionId.slice(0, 12)} (${rawObs.length} observations)`, + keyOutcomes: Array.from(tools).slice(0, 8), + filesAffected: Array.from(files).slice(0, 20), + lessons: lessonIds, + sourceActionIds: existingCrystal?.sourceActionIds ?? [], + sessionId, + project, + createdAt: existingCrystal?.createdAt ?? createdAt, + }; + await kv.set(KV.crystals, crystalId, crystal); + } catch {} +} + +function isRawShape(o: unknown): o is RawObservation { + if (!o || typeof o !== "object") return false; + const r = o as Record; + return typeof r.hookType === "string"; +} + +async function loadObservations( + kv: StateKV, + sessionId: string, +): Promise { + const rows = await kv.list( + KV.observations(sessionId), + ); + return rows.map((r) => (isRawShape(r) ? r : rawFromCompressed(r as CompressedObservation))); +} + +async function findJsonlFiles( + root: string, + limit = 200, +): Promise<{ + files: string[]; + truncated: boolean; + discovered: number; + traversalCapped: boolean; +}> { + const out: string[] = []; + let discovered = 0; + let walked = 0; + // Hard bound on entries visited (regardless of extension) so trees + // dominated by non-jsonl files (node_modules, lockfiles, etc.) cannot + // lock the 30s function timeout. `discovered` may underrepresent the + // true count when traversalCapped fires — callers should surface that + // distinction to the user. + const traversalCap = Math.max(limit * 50, 50_000); + async function walk(dir: string) { + if (walked >= traversalCap) return; + let names: string[]; + try { + names = await readdir(dir); + } catch { + return; + } + for (const name of names) { + if (walked >= traversalCap) return; + walked++; + const full = join(dir, name); + let st; + try { + st = await lstat(full); + } catch { + continue; + } + if (st.isSymbolicLink()) continue; + if (st.isDirectory()) { + await walk(full); + } else if (st.isFile() && name.endsWith(".jsonl")) { + discovered++; + if (out.length < limit) out.push(full); + } + } + } + await walk(root); + const traversalCapped = walked >= traversalCap; + return { + files: out, + truncated: discovered > out.length || traversalCapped, + discovered, + traversalCapped, + }; +} + +export function registerReplayFunctions(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction( + "mem::replay::load", + async (data: { sessionId: string }): Promise< + | { success: true; timeline: Timeline; session: Session | null } + | { success: false; error: string } + > => { + if (!data?.sessionId || typeof data.sessionId !== "string") { + return { success: false, error: "sessionId is required" }; + } + const session = await kv.get(KV.sessions, data.sessionId); + const observations = await loadObservations(kv, data.sessionId); + const timeline = projectTimeline(observations); + return { success: true, timeline, session }; + }, + ); + + sdk.registerFunction( + "mem::replay::sessions", + async (): Promise<{ success: true; sessions: Session[] }> => { + const sessions = await kv.list(KV.sessions); + sessions.sort((a, b) => (b.startedAt || "").localeCompare(a.startedAt || "")); + return { success: true, sessions }; + }, + ); + + sdk.registerFunction( + "mem::replay::import-jsonl", + async ( + data: { path?: string; maxFiles?: number } = {}, + ): Promise< + | { + success: true; + imported: number; + sessionIds: string[]; + observations: number; + discovered: number; + truncated: boolean; + traversalCapped: boolean; + maxFiles: number; + maxFilesUpperBound: number; + } + | { success: false; error: string } + > => { + const defaultRoot = join(homedir(), ".claude", "projects"); + const rawPath = data.path || defaultRoot; + if (typeof rawPath !== "string" || rawPath.length === 0) { + return { success: false, error: "path must be a non-empty string" }; + } + const expanded = rawPath.startsWith("~") + ? join(homedir(), rawPath.slice(1)) + : rawPath; + const abs = resolve(expanded); + if (isSensitive(abs)) { + return { success: false, error: "refusing to process sensitive-looking path" }; + } + if (await isSymlink(abs)) { + return { success: false, error: "symlinks are not supported" }; + } + + let stat; + try { + stat = await lstat(abs); + } catch { + return { success: false, error: "path not found" }; + } + + // Valid integer requests are clamped to MAX_FILES_UPPER_BOUND so + // callers see a stable maxFiles in the response. Non-integer or + // <= 0 falls back to the safe default. The HTTP layer rejects + // out-of-range up front; this is the SDK-callable safety net. + const maxFiles = + Number.isInteger(data.maxFiles) && (data.maxFiles as number) > 0 + ? Math.min(data.maxFiles as number, MAX_FILES_UPPER_BOUND) + : MAX_FILES_DEFAULT; + let files: string[] = []; + let truncated = false; + let discovered = 0; + let traversalCapped = false; + if (stat.isDirectory()) { + const found = await findJsonlFiles(abs, maxFiles); + files = found.files; + truncated = found.truncated; + discovered = found.discovered; + traversalCapped = found.traversalCapped; + } else if (stat.isFile() && abs.endsWith(".jsonl")) { + files = [abs]; + discovered = 1; + } else { + return { success: false, error: "path must be a .jsonl file or directory" }; + } + + if (files.length === 0) { + return { + success: true, + imported: 0, + sessionIds: [], + observations: 0, + discovered, + truncated, + traversalCapped, + maxFiles, + maxFilesUpperBound: MAX_FILES_UPPER_BOUND, + }; + } + + const sessionIds: string[] = []; + let observationCount = 0; + + for (const file of files) { + if (isSensitive(file)) continue; + if (await isSymlink(file)) continue; + let text: string; + try { + text = await readFile(file, "utf-8"); + } catch (err) { + logger.warn("replay: failed to read jsonl", { + file, + error: err instanceof Error ? err.message : String(err), + }); + continue; + } + + const parsed = parseJsonlText(text, generateId("sess")); + if (parsed.observations.length === 0) continue; + + const firstPromptObs = parsed.observations.find( + (o) => typeof o.userPrompt === "string" && o.userPrompt.trim().length > 0, + ); + const firstPrompt = firstPromptObs?.userPrompt + ? firstPromptObs.userPrompt.replace(/\s+/g, " ").trim().slice(0, 200) + : undefined; + + const existing = await kv.get(KV.sessions, parsed.sessionId); + if (existing) { + existing.observationCount = + (existing.observationCount || 0) + parsed.observations.length; + if (parsed.endedAt > (existing.endedAt || "")) { + existing.endedAt = parsed.endedAt; + } + if (existing.status === "active") existing.status = "completed"; + const existingTags = existing.tags || []; + if (!existingTags.includes("jsonl-import")) { + existing.tags = [...existingTags, "jsonl-import"]; + } + if (!existing.firstPrompt && firstPrompt) { + existing.firstPrompt = firstPrompt; + } + // #775: re-key on parsed.sessionId, not existing.id. Older + // session rows may be missing the `id` field; existing.id + // would then be undefined, JSON.stringify would drop the + // `key` from the state::set payload, and the engine would + // reject the call with `missing field \`key\``. Because the + // rejection aborts the whole import handler, a single + // legacy row killed the entire batch. parsed.sessionId is + // always populated (parseJsonlText has a three-level + // fallback) and is what we just used to read the row. + if (!existing.id) existing.id = parsed.sessionId; + await kv.set(KV.sessions, parsed.sessionId, existing); + } else { + const session: Session = { + id: parsed.sessionId, + project: parsed.project, + cwd: parsed.cwd, + startedAt: parsed.startedAt, + endedAt: parsed.endedAt, + status: "completed", + observationCount: parsed.observations.length, + tags: ["jsonl-import"], + firstPrompt, + }; + await kv.set(KV.sessions, session.id, session); + } + + const searchIndex = getSearchIndex(); + const compressed: CompressedObservation[] = []; + await Promise.all( + parsed.observations.map(async (obs) => { + const synthetic = buildSyntheticCompression(obs); + compressed.push(synthetic); + await kv.set(KV.observations(parsed.sessionId), obs.id, synthetic); + searchIndex.add(synthetic); + }), + ); + observationCount += parsed.observations.length; + sessionIds.push(parsed.sessionId); + + await deriveCrystalAndLessons( + kv, + parsed.sessionId, + parsed.project, + parsed.observations, + compressed, + firstPrompt, + ); + } + + await safeAudit(kv, "import", "mem::replay::import-jsonl", sessionIds, { + source: "jsonl", + path: abs, + files: files.length, + observations: observationCount, + }); + + return { + success: true, + imported: files.length, + sessionIds, + observations: observationCount, + discovered, + truncated, + traversalCapped, + maxFiles, + maxFilesUpperBound: MAX_FILES_UPPER_BOUND, + }; + }, + ); +} diff --git a/src/functions/retention.ts b/src/functions/retention.ts new file mode 100644 index 0000000..fd3e646 --- /dev/null +++ b/src/functions/retention.ts @@ -0,0 +1,412 @@ +import type { ISdk } from "iii-sdk"; +import type { + Memory, + SemanticMemory, + RetentionScore, + DecayConfig, +} from "../types.js"; +import { KV } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import type { AccessLog } from "./access-tracker.js"; +import { + emptyAccessLog, + deleteAccessLog, + normalizeAccessLog, +} from "./access-tracker.js"; +import { recordAudit } from "./audit.js"; +import { getSearchIndex, vectorIndexRemove, flushIndexSave } from "./search.js"; +import { logger } from "../logger.js"; + +const DEFAULT_DECAY: DecayConfig = { + lambda: 0.01, + sigma: 0.3, + tierThresholds: { + hot: 0.7, + warm: 0.4, + cold: 0.15, + }, +}; + +function resolveDecayConfig( + input?: Partial, +): { config: DecayConfig } | { error: string } { + const tierThresholds = { + ...DEFAULT_DECAY.tierThresholds, + ...(input?.tierThresholds ?? {}), + }; + const config: DecayConfig = { + lambda: + typeof input?.lambda === "number" ? input.lambda : DEFAULT_DECAY.lambda, + sigma: typeof input?.sigma === "number" ? input.sigma : DEFAULT_DECAY.sigma, + tierThresholds, + }; + + if (!Number.isFinite(config.lambda) || config.lambda <= 0) { + return { error: "config.lambda must be a positive number" }; + } + if (!Number.isFinite(config.sigma) || config.sigma < 0) { + return { error: "config.sigma must be a non-negative number" }; + } + const { hot, warm, cold } = config.tierThresholds; + if (![hot, warm, cold].every((v) => Number.isFinite(v))) { + return { + error: "config.tierThresholds.hot/warm/cold must be finite numbers", + }; + } + if (!(hot >= warm && warm >= cold && cold >= 0)) { + return { + error: + "config.tierThresholds must satisfy hot >= warm >= cold >= 0", + }; + } + return { config }; +} + +function computeReinforcementBoost( + accessTimestamps: number[], + sigma: number, +): number { + const now = Date.now(); + let boost = 0; + for (const tAccess of accessTimestamps) { + if (!Number.isFinite(tAccess)) continue; + const daysSinceAccess = (now - tAccess) / (1000 * 60 * 60 * 24); + if (daysSinceAccess > 0) { + boost += 1 / daysSinceAccess; + } + } + return boost * sigma; +} + +function computeRetention( + salience: number, + createdAt: string, + accessTimestamps: number[], + config: DecayConfig, +): number { + const deltaT = + (Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24); + const temporalDecay = Math.exp(-config.lambda * deltaT); + const reinforcementBoost = computeReinforcementBoost( + accessTimestamps, + config.sigma, + ); + return Math.min(1, salience * temporalDecay + reinforcementBoost); +} + +function computeSalience( + memory: Memory | SemanticMemory, + accessCount: number, +): number { + let baseSalience = 0.5; + + if ("type" in memory) { + const typeWeights: Record = { + architecture: 0.9, + bug: 0.7, + pattern: 0.8, + preference: 0.85, + workflow: 0.6, + fact: 0.5, + }; + baseSalience = typeWeights[(memory as Memory).type] || 0.5; + } + + if ("confidence" in memory) { + baseSalience = Math.max(baseSalience, (memory as SemanticMemory).confidence); + } + + const accessBonus = Math.min(0.2, accessCount * 0.02); + return Math.min(1, baseSalience + accessBonus); +} + +export function registerRetentionFunctions( + sdk: ISdk, + kv: StateKV, +): void { + sdk.registerFunction("mem::retention-score", + async (data: { config?: Partial }) => { + const resolved = resolveDecayConfig(data?.config); + if ("error" in resolved) { + return { success: false, error: resolved.error }; + } + const { config } = resolved; + + const [memories, semanticMems, allLogs] = await Promise.all([ + kv.list(KV.memories), + kv.list(KV.semantic), + kv.list(KV.accessLog).catch(() => [] as unknown[]), + ]); + const logsById = new Map(); + for (const raw of allLogs) { + const log = normalizeAccessLog(raw); + if (log.memoryId) logsById.set(log.memoryId, log); + } + + const scores: RetentionScore[] = []; + + const computeDecay = (createdAt: string): number => + Math.exp( + -config.lambda * + ((Date.now() - new Date(createdAt).getTime()) / + (1000 * 60 * 60 * 24)), + ); + + // Build all entries in memory first, then flush with Promise.all + // so a full rescore is one batched KV write instead of N sequential + // round-trips. Separate counts for the audit record at the end. + const pendingWrites: Array<[string, RetentionScore]> = []; + let episodicScored = 0; + let semanticScored = 0; + + for (const mem of memories) { + if (!mem.isLatest) continue; + const log = logsById.get(mem.id) ?? emptyAccessLog(mem.id); + const salience = computeSalience(mem, log.count); + const temporalDecay = computeDecay(mem.createdAt); + const reinforcementBoost = computeReinforcementBoost( + log.recent, + config.sigma, + ); + const score = Math.min( + 1, + salience * temporalDecay + reinforcementBoost, + ); + + const entry: RetentionScore = { + memoryId: mem.id, + source: "episodic", + score, + salience, + temporalDecay, + reinforcementBoost, + lastAccessed: log.lastAt || mem.updatedAt, + accessCount: log.count, + }; + + scores.push(entry); + pendingWrites.push([mem.id, entry]); + episodicScored++; + } + + for (const sem of semanticMems) { + const log = logsById.get(sem.id) ?? emptyAccessLog(sem.id); + + // Pre-0.8.3 fallback: use sem.lastAccessedAt only when mem:access is empty. + let accessTimestamps: number[]; + let effectiveCount: number; + if (log.recent.length > 0 || log.count > 0) { + accessTimestamps = log.recent; + effectiveCount = log.count; + } else if (sem.lastAccessedAt) { + const legacyTs = Date.parse(sem.lastAccessedAt); + accessTimestamps = Number.isFinite(legacyTs) ? [legacyTs] : []; + effectiveCount = sem.accessCount; + } else { + accessTimestamps = []; + effectiveCount = sem.accessCount; + } + + const salience = computeSalience(sem, effectiveCount); + const temporalDecay = computeDecay(sem.createdAt); + const reinforcementBoost = computeReinforcementBoost( + accessTimestamps, + config.sigma, + ); + const score = Math.min( + 1, + salience * temporalDecay + reinforcementBoost, + ); + + const entry: RetentionScore = { + memoryId: sem.id, + source: "semantic", + score, + salience, + temporalDecay, + reinforcementBoost, + lastAccessed: log.lastAt || sem.lastAccessedAt, + accessCount: effectiveCount, + }; + + scores.push(entry); + pendingWrites.push([sem.id, entry]); + semanticScored++; + } + + // Flush all retention rows in parallel. N sequential writes was + // making full rescores O(n) round-trips on stores with 1000+ + // memories; batching drops that to O(1) wall time on the KV + // backends that can pipeline. + await Promise.all( + pendingWrites.map(([id, entry]) => + kv.set(KV.retentionScores, id, entry), + ), + ); + + scores.sort((a, b) => b.score - a.score); + + const tiers = { + hot: scores.filter((s) => s.score >= config.tierThresholds.hot) + .length, + warm: scores.filter( + (s) => + s.score >= config.tierThresholds.warm && + s.score < config.tierThresholds.hot, + ).length, + cold: scores.filter( + (s) => + s.score >= config.tierThresholds.cold && + s.score < config.tierThresholds.warm, + ).length, + evictable: scores.filter( + (s) => s.score < config.tierThresholds.cold, + ).length, + }; + + logger.info("Retention scores computed", { + total: scores.length, + ...tiers, + }); + + // Audit the rescore as a single batched event per sweep. We + // intentionally pass an empty targetIds array — a mature store + // can have 1000+ memory ids per rescore and flooding the audit + // log with every memoryId on every cron tick is worse than + // recording just the summary. The details payload has enough + // context for observability (counts per source + per tier). + if (scores.length > 0) { + await recordAudit(kv, "retention_score", "mem::retention-score", [], { + total: scores.length, + episodic: episodicScored, + semantic: semanticScored, + tiers, + config, + }); + } + + return { success: true, total: scores.length, tiers, scores }; + }, + ); + + sdk.registerFunction("mem::retention-evict", + async (data?: { + threshold?: number; + dryRun?: boolean; + maxEvict?: number; + }) => { + const threshold = + typeof data?.threshold === "number" && Number.isFinite(data.threshold) + ? data.threshold + : DEFAULT_DECAY.tierThresholds.cold; + const maxEvictRaw = + typeof data?.maxEvict === "number" && Number.isInteger(data.maxEvict) + ? data.maxEvict + : 50; + const maxEvict = Math.min(1000, Math.max(0, maxEvictRaw)); + const { decrementImageRef } = await import("./image-refs.js"); + + const allScores = await kv.list(KV.retentionScores); + const candidates = allScores + .filter((s) => s.score < threshold) + .sort((a, b) => a.score - b.score) + .slice(0, maxEvict); + + if (data?.dryRun) { + return { + success: true, + dryRun: true, + wouldEvict: candidates.length, + candidates: candidates.map((c) => ({ + id: c.memoryId, + score: c.score, + })), + }; + } + + // Branch on source (#124). Pre-0.8.10 rows have no `source` field, + // and that includes semantic retention rows that were written by + // the old scorer — so we can't just default to episodic, that + // would silently no-op the delete and leave the stranded semantic + // memory alive (the exact bug #124 is about). When `source` is + // missing, probe both namespaces to find where the memoryId + // actually lives and route the delete there. After one re-score + // (mem::retention-score) every row will have the correct tag. + let evicted = 0; + let evictedEpisodic = 0; + let evictedSemantic = 0; + const evictedIds: string[] = []; + for (const candidate of candidates) { + try { + let scope: string | null = null; + let resolvedSource: "episodic" | "semantic" | null = null; + if (candidate.source === "semantic") { + scope = KV.semantic; + resolvedSource = "semantic"; + } else if (candidate.source === "episodic") { + scope = KV.memories; + resolvedSource = "episodic"; + } else { + const episodic = await kv.get(KV.memories, candidate.memoryId); + if (episodic !== null) { + scope = KV.memories; + resolvedSource = "episodic"; + } else { + const semantic = await kv.get(KV.semantic, candidate.memoryId); + if (semantic !== null) { + scope = KV.semantic; + resolvedSource = "semantic"; + } + } + } + + if (!scope || !resolvedSource) { + continue; + } + + const mem = await kv.get(scope, candidate.memoryId); + if (mem && mem.imageRef) { + await decrementImageRef(kv, sdk, mem.imageRef); + } + await kv.delete(scope, candidate.memoryId); + await kv.delete(KV.retentionScores, candidate.memoryId); + await deleteAccessLog(kv, candidate.memoryId); + getSearchIndex().remove(candidate.memoryId); + vectorIndexRemove(candidate.memoryId); + evicted++; + evictedIds.push(candidate.memoryId); + if (resolvedSource === "semantic") evictedSemantic++; + else evictedEpisodic++; + } catch { + continue; + } + } + + // Retention eviction is a structural delete path that removes + // memories, retention scores, and access logs, so it needs to + // emit an audit record per the repo's audit-coverage policy (see + // mem::governance-delete for the reference pattern). Batched, + // one record per invocation — per-candidate audits would flood + // the audit log during normal eviction sweeps. + if (evicted > 0) { + await flushIndexSave(); + await recordAudit(kv, "delete", "mem::retention-evict", evictedIds, { + threshold, + evicted, + evictedEpisodic, + evictedSemantic, + reason: "retention score below threshold", + }); + } + + logger.info("Retention-based eviction complete", { + evicted, + evictedEpisodic, + evictedSemantic, + threshold, + }); + + return { success: true, evicted, evictedEpisodic, evictedSemantic }; + }, + ); +} diff --git a/src/functions/routines.ts b/src/functions/routines.ts new file mode 100644 index 0000000..d1a9135 --- /dev/null +++ b/src/functions/routines.ts @@ -0,0 +1,303 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import type { Action, Routine, RoutineStep, RoutineRun } from "../types.js"; +import { recordAudit } from "./audit.js"; + +export function registerRoutinesFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::routine-create", + async (data: { + name: string; + description?: string; + steps: RoutineStep[]; + tags?: string[]; + frozen?: boolean; + sourceProceduralIds?: string[]; + }) => { + if (!data.name || !Array.isArray(data.steps) || data.steps.length === 0) { + return { success: false, error: "name and steps are required" }; + } + + for (let i = 0; i < data.steps.length; i++) { + if (!data.steps[i].title?.trim()) { + return { success: false, error: `step ${i} must have a title` }; + } + } + + const orders = data.steps.map((s, i) => s.order ?? i); + const uniqueOrders = new Set(orders); + if (uniqueOrders.size !== orders.length) { + return { success: false, error: "duplicate step orders" }; + } + for (const step of data.steps) { + if (step.dependsOn) { + for (const dep of step.dependsOn) { + if (!uniqueOrders.has(dep)) { + return { success: false, error: `step ${step.order ?? data.steps.indexOf(step)} depends on unknown order ${dep}` }; + } + } + } + } + + const now = new Date().toISOString(); + const routine: Routine = { + id: generateId("rtn"), + name: data.name.trim(), + description: (data.description || "").trim(), + steps: data.steps.map((s, i) => ({ + order: s.order ?? i, + title: s.title, + description: s.description || "", + actionTemplate: s.actionTemplate || {}, + dependsOn: s.dependsOn || [], + })), + createdAt: now, + updatedAt: now, + frozen: data.frozen ?? true, + tags: data.tags || [], + sourceProceduralIds: data.sourceProceduralIds || [], + }; + + await kv.set(KV.routines, routine.id, routine); + await recordAudit(kv, "routine_run", "mem::routine-create", [routine.id], { + action: "routine.create", + stepCount: routine.steps.length, + }); + return { success: true, routine }; + }, + ); + + sdk.registerFunction("mem::routine-list", + async (data: { frozen?: boolean; tags?: string[] }) => { + let routines = await kv.list(KV.routines); + if (data.frozen !== undefined) { + routines = routines.filter((r) => r.frozen === data.frozen); + } + if (data.tags && data.tags.length > 0) { + routines = routines.filter((r) => + data.tags!.some((t) => r.tags.includes(t)), + ); + } + routines.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + return { success: true, routines }; + }, + ); + + sdk.registerFunction("mem::routine-run", + async (data: { + routineId: string; + initiatedBy?: string; + project?: string; + overrides?: Record>; + }) => { + if (!data.routineId) { + return { success: false, error: "routineId is required" }; + } + + return withKeyedLock(`mem:routine:${data.routineId}`, async () => { + const routine = await kv.get(KV.routines, data.routineId); + if (!routine) { + return { success: false, error: "routine not found" }; + } + + const now = new Date().toISOString(); + const stepOrderToActionId = new Map(); + const actionIds: string[] = []; + const stepStatus: Record = {}; + + for (const step of routine.steps) { + const template = step.actionTemplate || {}; + const override = data.overrides?.[step.order] || {}; + + const hasDeps = (step.dependsOn || []).length > 0; + const action: Action = { + id: generateId("act"), + title: override.title || template.title || step.title, + description: + override.description || + template.description || + step.description, + status: hasDeps ? "blocked" : "pending", + priority: + override.priority ?? template.priority ?? 5, + createdAt: now, + updatedAt: now, + createdBy: data.initiatedBy || "routine", + project: data.project || template.project, + tags: [ + ...(template.tags || []), + ...(override.tags || []), + `routine:${routine.id}`, + ], + sourceObservationIds: [], + sourceMemoryIds: [], + metadata: { routineId: routine.id, stepOrder: step.order }, + }; + + await kv.set(KV.actions, action.id, action); + stepOrderToActionId.set(step.order, action.id); + actionIds.push(action.id); + stepStatus[step.order] = "pending"; + } + + for (const step of routine.steps) { + const actionId = stepOrderToActionId.get(step.order); + if (!actionId) continue; + + for (const depOrder of step.dependsOn) { + const depActionId = stepOrderToActionId.get(depOrder); + if (!depActionId) continue; + const edge = { + id: generateId("ae"), + type: "requires" as const, + sourceActionId: actionId, + targetActionId: depActionId, + createdAt: now, + }; + await kv.set(KV.actionEdges, edge.id, edge); + } + } + + const run: RoutineRun = { + id: generateId("run"), + routineId: routine.id, + status: "running", + startedAt: now, + actionIds, + stepStatus, + initiatedBy: data.initiatedBy || "unknown", + }; + + await kv.set(KV.routineRuns, run.id, run); + await recordAudit(kv, "routine_run", "mem::routine-run", [run.id], { + action: "routine.run", + routineId: routine.id, + actionIds, + initiatedBy: data.initiatedBy || "unknown", + }); + + return { + success: true, + run, + actionsCreated: actionIds.length, + }; + }); + }, + ); + + sdk.registerFunction("mem::routine-status", + async (data: { runId: string }) => { + if (!data.runId) { + return { success: false, error: "runId is required" }; + } + + const run = await kv.get(KV.routineRuns, data.runId); + if (!run) { + return { success: false, error: "run not found" }; + } + + const actionStates: Array<{ + actionId: string; + status: string; + title: string; + }> = []; + let allDone = true; + let anyFailed = false; + + let statusChanged = false; + for (const actionId of run.actionIds) { + const action = await kv.get(KV.actions, actionId); + if (action) { + actionStates.push({ + actionId: action.id, + status: action.status, + title: action.title, + }); + if (action.status !== "done") allDone = false; + if (action.status === "cancelled") anyFailed = true; + + const stepOrder = (action.metadata as { stepOrder?: number })?.stepOrder; + if (stepOrder !== undefined && stepOrder in run.stepStatus) { + let mapped: "pending" | "active" | "done" | "failed"; + if (action.status === "cancelled") { + mapped = "failed"; + } else if (action.status === "blocked") { + mapped = "pending"; + } else { + mapped = action.status as "pending" | "active" | "done"; + } + if (run.stepStatus[stepOrder] !== mapped) { + run.stepStatus[stepOrder] = mapped; + statusChanged = true; + } + } + } else { + actionStates.push({ + actionId, + status: "cancelled", + title: "(missing)", + }); + allDone = false; + anyFailed = true; + } + } + + if (allDone && run.status === "running") { + run.status = "completed"; + run.completedAt = new Date().toISOString(); + statusChanged = true; + } else if (anyFailed && run.status === "running") { + run.status = "failed"; + statusChanged = true; + } + + if (statusChanged) { + await kv.set(KV.routineRuns, run.id, run); + await recordAudit(kv, "routine_run", "mem::routine-status", [run.id], { + action: "routine.status", + status: run.status, + }); + } + + return { + success: true, + run, + actions: actionStates, + progress: { + total: run.actionIds.length, + done: actionStates.filter((a) => a.status === "done").length, + active: actionStates.filter((a) => a.status === "active").length, + pending: actionStates.filter((a) => a.status === "pending").length, + blocked: actionStates.filter((a) => a.status === "blocked").length, + cancelled: actionStates.filter((a) => a.status === "cancelled").length, + }, + }; + }, + ); + + sdk.registerFunction("mem::routine-freeze", + async (data: { routineId: string }) => { + if (!data.routineId) { + return { success: false, error: "routineId is required" }; + } + return withKeyedLock(`mem:routine:${data.routineId}`, async () => { + const routine = await kv.get(KV.routines, data.routineId); + if (!routine) { + return { success: false, error: "routine not found" }; + } + routine.frozen = true; + routine.updatedAt = new Date().toISOString(); + await kv.set(KV.routines, routine.id, routine); + await recordAudit(kv, "routine_run", "mem::routine-freeze", [routine.id], { + action: "routine.freeze", + frozen: true, + }); + return { success: true, routine }; + }); + }, + ); +} diff --git a/src/functions/search.ts b/src/functions/search.ts new file mode 100644 index 0000000..df699a1 --- /dev/null +++ b/src/functions/search.ts @@ -0,0 +1,601 @@ +import type { ISdk } from 'iii-sdk' +import type { CompactSearchResult, CompressedObservation, Memory, SearchResult, Session } from '../types.js' +import { KV } from '../state/schema.js' +import { StateKV } from '../state/kv.js' +import { SearchIndex } from '../state/search-index.js' +import { VectorIndex } from '../state/vector-index.js' +import type { EmbeddingProvider } from '../types.js' +import { memoryToObservation } from '../state/memory-utils.js' +import { recordAccessBatch } from './access-tracker.js' +import { logger } from "../logger.js"; +import { getAgentId, isAgentScopeIsolated } from "../config.js"; + +let index: SearchIndex | null = null +let vectorIndex: VectorIndex | null = null +let currentEmbeddingProvider: EmbeddingProvider | null = null + +export function getSearchIndex(): SearchIndex { + if (!index) index = new SearchIndex() + return index +} + +export function setVectorIndex(idx: VectorIndex | null): void { + vectorIndex = idx +} + +export function getVectorIndex(): VectorIndex | null { + return vectorIndex +} + +export function setEmbeddingProvider(provider: EmbeddingProvider | null): void { + currentEmbeddingProvider = provider +} + +export function getEmbeddingProvider(): EmbeddingProvider | null { + return currentEmbeddingProvider +} + +export function vectorIndexRemove(id: string): void { + vectorIndex?.remove(id); +} + +// Persistence sync hook. Without this, index removals only live in +// memory; a crash/SIGKILL before graceful shutdown reloads a stale +// snapshot at boot and the deleted entry resurrects in the index. +// Wired by src/index.ts after IndexPersistence is constructed; no-op +// until then so unit tests that exercise the delete paths in +// isolation don't need to wire persistence. +let indexPersistence: { + scheduleSave: () => void; + save: () => Promise; +} | null = null; + +export function setIndexPersistence( + p: { scheduleSave: () => void; save: () => Promise } | null, +): void { + indexPersistence = p; +} + +export function scheduleIndexSave(): void { + indexPersistence?.scheduleSave(); +} + +// Synchronous flush variant for delete paths. The debounced +// scheduleSave is fine for adds (chatty), but a hard process exit +// inside the 5s debounce window would lose deletes and resurrect +// removed entries on next boot. Deletes are infrequent enough that +// awaiting a single write per operation is acceptable. save() catches +// its own errors via IndexPersistence.logFailure, so this resolves +// even when persistence fails — callers must not treat a failed +// flush as a fatal error on the delete itself (the KV delete already +// committed before this is invoked). +export async function flushIndexSave(): Promise { + await indexPersistence?.save(); +} + +// Hard cap on embedding input length. Most providers cap input around +// 8k tokens (~32k chars at ~4 chars/token). Truncate defensively so a +// huge memory.content can't 400 the embed call or blow context budget +// on a single doc. 16k chars ≈ 4k tokens, safely under every provider. +const EMBED_MAX_CHARS = 16_000 + +export function clipEmbedInput(text: string): string { + if (text.length <= EMBED_MAX_CHARS) return text + return text.slice(0, EMBED_MAX_CHARS) +} + +// Single guarded vector-index write. Returns true on success. Logs and +// no-ops on: +// - dimension mismatch (mis-configured provider would silently corrupt +// the index per #248 otherwise — guarded at persistence load there; +// this is the symmetric guard at the write site) +// - embed throwing (network, rate limit, provider down) +// Always soft-fails so a downed embedder doesn't break the upstream save. +export async function vectorIndexAddGuarded( + id: string, + sessionId: string, + text: string, + context: { kind: "memory" | "observation" | "synthetic"; logId: string }, +): Promise { + const vi = vectorIndex + const ep = currentEmbeddingProvider + if (!vi || !ep) return false + try { + const embedding = await ep.embed(clipEmbedInput(text)) + if (embedding.length !== ep.dimensions) { + logger.warn("vector-index add: dimension mismatch — skipping", { + kind: context.kind, + id: context.logId, + provider: ep.name, + expected: ep.dimensions, + received: embedding.length, + }) + return false + } + vi.add(id, sessionId, embedding) + return true + } catch (err) { + logger.warn("vector-index add: embed failed — skipping", { + kind: context.kind, + id: context.logId, + provider: ep.name, + error: err instanceof Error ? err.message : String(err), + }) + return false + } +} + +// Batched variant: calls EmbeddingProvider.embedBatch ONCE for the whole +// batch, then writes each resulting vector. Use this for bulk paths +// (rebuildIndex, future bulk-add APIs) where per-item serial awaits +// dominate wallclock. A batch of N has roughly the latency of a single +// embed (network + GPU setup amortized), so backfilling a 500k-obs +// corpus drops from days to hours on a per-batch endpoint like vLLM. +// +// Per-item failure shape: +// - whole-batch network/provider error → all skipped, single warn line +// - per-item dimension mismatch → that item skipped, others continue +export async function vectorIndexAddBatchGuarded( + items: Array<{ + id: string + sessionId: string + text: string + context: { kind: "memory" | "observation" | "synthetic"; logId: string } + }>, +): Promise<{ ok: number; fail: number }> { + const vi = vectorIndex + const ep = currentEmbeddingProvider + if (!vi || !ep || items.length === 0) return { ok: 0, fail: 0 } + + let embeddings: Float32Array[] + try { + embeddings = await ep.embedBatch(items.map((i) => clipEmbedInput(i.text))) + } catch (err) { + logger.warn("vector-index add batch: embed failed — skipping batch", { + batchSize: items.length, + provider: ep.name, + error: err instanceof Error ? err.message : String(err), + }) + return { ok: 0, fail: items.length } + } + + if (embeddings.length !== items.length) { + logger.warn( + "vector-index add batch: provider returned wrong length — skipping batch", + { + batchSize: items.length, + returned: embeddings.length, + provider: ep.name, + }, + ) + return { ok: 0, fail: items.length } + } + + let ok = 0 + let fail = 0 + for (let i = 0; i < items.length; i++) { + const item = items[i] + const embedding = embeddings[i] + if (embedding.length !== ep.dimensions) { + logger.warn("vector-index add batch: dimension mismatch — skipping item", { + kind: item.context.kind, + id: item.context.logId, + provider: ep.name, + expected: ep.dimensions, + received: embedding.length, + }) + fail++ + continue + } + try { + vi.add(item.id, item.sessionId, embedding) + ok++ + } catch (err) { + logger.warn("vector-index add batch: index write failed — skipping item", { + kind: item.context.kind, + id: item.context.logId, + error: err instanceof Error ? err.message : String(err), + }) + fail++ + } + } + return { ok, fail } +} + +// Embed-batch size for rebuild. Each item is one /v1/embeddings call's +// `input` array element; the provider sees the whole batch as one HTTP +// round-trip. 32 fits comfortably under typical per-request token budgets +// (32 × ~110 tok/item ≈ 3.5k tokens) and gets close to per-call +// throughput for GPU-backed endpoints (vLLM, Triton, etc.). Override via +// REBUILD_EMBED_BATCH_SIZE for endpoints that prefer smaller/larger +// batches. Set to 1 to fall back to the legacy per-item path. +const DEFAULT_REBUILD_EMBED_BATCH = 32 + +function getRebuildEmbedBatchSize(): number { + const raw = process.env.REBUILD_EMBED_BATCH_SIZE + if (!raw) return DEFAULT_REBUILD_EMBED_BATCH + const n = parseInt(raw, 10) + return Number.isFinite(n) && n > 0 ? n : DEFAULT_REBUILD_EMBED_BATCH +} + +export async function rebuildIndex(kv: StateKV): Promise { + const idx = getSearchIndex() + idx.clear() + + // BM25 clear above wipes stale doc entries; the vector index has the + // symmetric concern — memories/observations deleted between runs + // would leave orphan embeddings here forever. Clear both before the + // repopulation loops run, so BM25 and vector stay in sync. + vectorIndex?.clear() + + const batchSize = getRebuildEmbedBatchSize() + // Accumulator for the batched embed flush. BM25 add is synchronous and + // doesn't need batching — only the vector path benefits. + type EmbedJob = { + id: string + sessionId: string + text: string + context: { kind: "memory" | "observation" | "synthetic"; logId: string } + } + const pending: EmbedJob[] = [] + let count = 0 + + const flush = async (): Promise => { + if (pending.length === 0) return + await vectorIndexAddBatchGuarded(pending) + pending.length = 0 + } + const enqueue = async (job: EmbedJob): Promise => { + pending.push(job) + if (pending.length >= batchSize) await flush() + } + + // Memories live in their own KV scope outside per-session observation + // scopes, so they need a separate walk. Without this, mem::remember + // entries vanish from BM25 on every restart even after the live-write + // fix in remember.ts (#257). + try { + const memories = await kv.list(KV.memories) + for (const memory of memories) { + if (memory.isLatest === false) continue + if (!memory.title || !memory.content) continue + idx.add(memoryToObservation(memory)) + await enqueue({ + id: memory.id, + sessionId: memory.sessionIds?.[0] ?? 'memory', + text: memory.title + ' ' + memory.content, + context: { kind: "memory", logId: memory.id }, + }) + count++ + } + } catch (err) { + logger.warn('rebuildIndex: failed to load memories', { + error: err instanceof Error ? err.message : String(err), + }) + } + + const sessions = await kv.list(KV.sessions) + if (!sessions.length) { + await flush() + return count + } + + const obsPerSession: CompressedObservation[][] = [] + const failedSessions: string[] = [] + for (let batch = 0; batch < sessions.length; batch += 10) { + const chunk = sessions.slice(batch, batch + 10) + const results = await Promise.all( + chunk.map(async (s) => { + try { + return await kv.list(KV.observations(s.id)) + } catch { + failedSessions.push(s.id) + return [] as CompressedObservation[] + } + }) + ) + obsPerSession.push(...results) + } + if (failedSessions.length > 0) { + logger.warn('rebuildIndex: failed to load observations for sessions', { failedSessions }) + } + for (const observations of obsPerSession) { + for (const obs of observations) { + if (obs.title && obs.narrative) { + idx.add(obs) + await enqueue({ + id: obs.id, + sessionId: obs.sessionId, + text: obs.title + ' ' + obs.narrative, + context: { kind: "observation", logId: obs.id }, + }) + count++ + } + } + } + + // Drain the last partial batch. + await flush() + return count +} + +export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction( + 'mem::search', + async (data: { + query: string + limit?: number + project?: string + cwd?: string + format?: string + token_budget?: number + agentId?: string + }) => { + const idx = getSearchIndex() + + // Input validation / normalization. + if (typeof data?.query !== 'string' || !data.query.trim()) { + throw new Error('mem::search: query must be a non-empty string') + } + const query = data.query.trim() + const MAX_LIMIT = 100 + let effectiveLimit = 20 + if (data.limit !== undefined) { + if (!Number.isInteger(data.limit) || data.limit < 1) { + throw new Error('mem::search: limit must be a positive integer') + } + effectiveLimit = Math.min(data.limit, MAX_LIMIT) + } + const projectFilter = typeof data.project === 'string' && data.project.trim().length > 0 ? data.project.trim() : undefined + const cwdFilter = typeof data.cwd === 'string' && data.cwd.trim().length > 0 ? data.cwd.trim() : undefined + // #817: agent-scope isolation. mem::search backs REST /search, + // memory_recall and recall_context. Without filtering here a + // worker booted with AGENT_ID=B + AGENTMEMORY_AGENT_SCOPE=isolated + // could read A's memories — the cross-agent leak the issue + // documented. Mirrors the smart-search pattern: wildcard "*" + // bypasses, explicit agentId pins, isolated mode falls back to + // the worker's own AGENT_ID. + // + // Fail-closed: if isolated mode is on AND no explicit agentId + // is given AND env AGENT_ID is unset, refuse the call rather + // than silently dropping the filter. Allowing the call through + // with filterAgentId=undefined is the same leak this fix is + // supposed to close. + const isolated = isAgentScopeIsolated(); + const explicitAgentId = + typeof data.agentId === "string" && data.agentId.trim().length > 0 + ? data.agentId.trim() + : undefined; + const wildcardAgent = explicitAgentId === "*"; + const envAgentId = isolated ? getAgentId() : undefined; + const filterAgentId = wildcardAgent + ? undefined + : explicitAgentId ?? envAgentId; + if ( + isolated && + !wildcardAgent && + !explicitAgentId && + !envAgentId + ) { + throw new Error( + "mem::search: AGENTMEMORY_AGENT_SCOPE=isolated is set but no " + + "agent id is available (env AGENT_ID unset and no explicit " + + "agentId in the call). Refusing to read cross-agent rows. " + + 'Pass agentId: "*" to opt in to a wildcard read.', + ); + } + const format = typeof data.format === 'string' ? data.format : 'full' + if (!['full', 'compact', 'narrative'].includes(format)) { + throw new Error("mem::search: format must be one of 'full', 'compact', or 'narrative'") + } + let tokenBudget: number | undefined + if (data.token_budget !== undefined) { + if (!Number.isInteger(data.token_budget) || data.token_budget < 1) { + throw new Error('mem::search: token_budget must be a positive integer') + } + tokenBudget = data.token_budget + } + + if (idx.size === 0) { + const count = await rebuildIndex(kv) + logger.info('Search index rebuilt', { entries: count }) + } + + // When filtering by project/cwd, over-fetch from the index so the + // post-filter still has a chance of returning `effectiveLimit` results. + // Over-fetch whenever ANY post-index filter is active. agentId + // is dropped after the observation/memory is loaded (BM25 index + // doesn't carry it), so without the over-fetch isolated-mode + // queries return underfilled pages when same-agent matches + // 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) + + // Resolve session -> project/cwd once per sessionId we touch. + const sessionCache = new Map() + const loadSession = async (sessionId: string): Promise => { + if (sessionCache.has(sessionId)) return sessionCache.get(sessionId)! + const s = await kv.get(KV.sessions, sessionId) + sessionCache.set(sessionId, s ?? null) + return s ?? null + } + + // Cache for memory project lookups. Memories indexed via mem::remember + // use a synthetic sessionId ('memory' or the first real sessionId) that + // either has no KV.sessions entry or belongs to a different project. + // When loadSession returns null we fall through to a KV.memories probe + // so project-filtered search can include or exclude them correctly. + const memoryProjectCache = new Map() + const loadMemoryProject = async (obsId: string): Promise => { + if (memoryProjectCache.has(obsId)) return memoryProjectCache.get(obsId)! + const mem = await kv.get(KV.memories, obsId).catch(() => null) + const proj = mem?.project ?? null + memoryProjectCache.set(obsId, proj) + return proj + } + + // First pass: filter by session (sequential — benefits from session cache). + // Memory entries with a synthetic sessionId take a secondary KV.memories + // path so project filtering works correctly for them too. + // + // When agentId filtering is active we can't cap at effectiveLimit + // here — the second pass (post-load) is what drops cross-agent + // rows, and capping early would underfill the result page. Use + // fetchLimit as the upper bound in that case; the final + // truncation lives at the end of the second pass. + const earlyCap = filterAgentId ? fetchLimit : effectiveLimit + const candidates: typeof results = [] + for (const r of results) { + if (candidates.length >= earlyCap) break + if (filtering) { + const s = await loadSession(r.sessionId) + if (s) { + if (projectFilter && s.project !== projectFilter) continue + if (cwdFilter && s.cwd !== cwdFilter) continue + } else { + // Session not found. Two cases arrive here: + // 1. Synthetic sessionId — memories indexed via mem::remember use + // sessionIds[0] ?? 'memory'. The string 'memory' has no session + // entry; neither does a real sessionId when sessionIds[0] happens + // to be a session from a different lifecycle. Probe KV.memories + // directly to get the memory's own project field. + // 2. Deleted session — the session existed when the entry was indexed + // but was since evicted. The KV.memories probe returns null for + // these (they are observations, not memories), so memProject is + // null and the entry passes through as unscoped. This is the safe + // fallback: we lose the ability to filter but never incorrectly + // block a result whose session we can no longer verify. + // In both cases, a null memProject means "project unknown — treat as + // unscoped and let it through" to preserve backward-compatibility. + if (projectFilter) { + const memProject = await loadMemoryProject(r.obsId) + if (memProject !== null && memProject !== projectFilter) continue + } + // cwd filter does not apply to unbound entries. + } + } + candidates.push(r) + } + + // Second pass: load observations in parallel. Fall back to + // KV.memories when the observation lookup misses — entries indexed + // via mem::remember live in the memories scope under a synthetic + // sessionId, so the observation key never exists (#265). + const obsResults = await Promise.all( + candidates.map(async (r) => { + const obs = await kv + .get(KV.observations(r.sessionId), r.obsId) + .catch(() => null) + if (obs) return obs + const mem = await kv + .get(KV.memories, r.obsId) + .catch(() => null) + return mem ? memoryToObservation(mem) : null + }) + ) + const enriched: SearchResult[] = [] + for (let i = 0; i < candidates.length; i++) { + const obs = obsResults[i] + if (!obs) continue + // #817: enforce agent-scope after the observation/memory is + // loaded. The BM25 index doesn't carry agentId so the filter + // happens post-lookup. Wildcard ("*") and no-isolation paths + // resolved filterAgentId=undefined upstream and pass through. + if (filterAgentId !== undefined && obs.agentId !== filterAgentId) continue + if (enriched.length >= effectiveLimit) break + enriched.push({ + observation: obs, + score: candidates[i].score, + sessionId: candidates[i].sessionId, + }) + } + + void recordAccessBatch( + kv, + enriched.map((r) => r.observation.id), + ) + + const estimateTokens = (value: unknown): number => + Math.max(1, Math.ceil(JSON.stringify(value).length / 3)) + + const applyTokenBudget = (items: T[]): { + items: T[] + used: number + truncated: boolean + } => { + if (!tokenBudget) return { items, used: items.reduce((sum, item) => sum + estimateTokens(item), 0), truncated: false } + const selected: T[] = [] + let used = 0 + for (const item of items) { + const itemTokens = estimateTokens(item) + if (used + itemTokens > tokenBudget) { + return { items: selected, used, truncated: selected.length < items.length } + } + selected.push(item) + used += itemTokens + } + return { items: selected, used, truncated: false } + } + + if (format === 'compact') { + const compactResults: CompactSearchResult[] = enriched.map((r) => ({ + obsId: r.observation.id, + sessionId: r.sessionId, + title: r.observation.title, + type: r.observation.type, + score: r.score, + timestamp: r.observation.timestamp, + })) + const packed = applyTokenBudget(compactResults) + return { + format, + results: packed.items, + tokens_used: packed.used, + tokens_budget: tokenBudget, + truncated: packed.truncated, + } + } + + if (format === 'narrative') { + const narrativeResults = enriched.map((r) => ({ + obsId: r.observation.id, + sessionId: r.sessionId, + title: r.observation.title, + narrative: r.observation.narrative, + score: r.score, + timestamp: r.observation.timestamp, + })) + const packed = applyTokenBudget(narrativeResults) + const text = packed.items + .map((r, index) => `${index + 1}. ${r.title}\n${r.narrative}`) + .join('\n\n') + return { + format, + results: packed.items, + text, + tokens_used: packed.used, + tokens_budget: tokenBudget, + truncated: packed.truncated, + } + } + + const packed = applyTokenBudget(enriched) + + // Avoid logging raw cwd/project (host paths). Log only that filters were active. + logger.info('Search completed', { + query, + results: packed.items.length, + hasProjectFilter: !!projectFilter, + hasCwdFilter: !!cwdFilter, + }) + return { + format, + results: packed.items, + tokens_used: packed.used, + tokens_budget: tokenBudget, + truncated: packed.truncated, + } + } + ) +} diff --git a/src/functions/sentinels.ts b/src/functions/sentinels.ts new file mode 100644 index 0000000..ed29aee --- /dev/null +++ b/src/functions/sentinels.ts @@ -0,0 +1,455 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import type { Action, ActionEdge, Checkpoint, CompressedObservation, FunctionMetrics, Sentinel, Session } from "../types.js"; +import { recordAudit } from "./audit.js"; + +const VALID_TYPES: Sentinel["type"][] = [ + "webhook", + "timer", + "threshold", + "pattern", + "approval", + "custom", +]; + +export function registerSentinelsFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::sentinel-create", + async (data: { + name: string; + type: Sentinel["type"]; + config?: Record; + linkedActionIds?: string[]; + expiresInMs?: number; + }) => { + if (!data.name || typeof data.name !== "string") { + return { success: false, error: "name is required" }; + } + if (!data.type || !VALID_TYPES.includes(data.type)) { + return { + success: false, + error: `type must be one of: ${VALID_TYPES.join(", ")}`, + }; + } + + if (data.type === "threshold") { + const cfg = data.config as + | { metric?: string; operator?: string; value?: number } + | undefined; + if ( + !cfg || + !cfg.metric || + !["gt", "lt", "eq"].includes(cfg.operator || "") || + typeof cfg.value !== "number" + ) { + return { + success: false, + error: + "threshold config requires metric, operator (gt|lt|eq), and numeric value", + }; + } + } + + if (data.type === "pattern") { + const cfg = data.config as { pattern?: string } | undefined; + if (!cfg || !cfg.pattern || typeof cfg.pattern !== "string") { + return { + success: false, + error: "pattern config requires a pattern string", + }; + } + } + + if (data.type === "webhook") { + const cfg = data.config as { path?: string } | undefined; + if (!cfg || !cfg.path || typeof cfg.path !== "string") { + return { + success: false, + error: "webhook config requires a path string", + }; + } + } + + if (data.type === "timer") { + const cfg = data.config as { durationMs?: number } | undefined; + if (!cfg || typeof cfg.durationMs !== "number" || cfg.durationMs <= 0) { + return { + success: false, + error: "timer config requires a positive durationMs", + }; + } + } + + if (data.linkedActionIds && data.linkedActionIds.length > 0) { + for (const actionId of data.linkedActionIds) { + const action = await kv.get(KV.actions, actionId); + if (!action) { + return { + success: false, + error: `linked action not found: ${actionId}`, + }; + } + } + } + + const now = new Date(); + const sentinel: Sentinel = { + id: generateId("snl"), + name: data.name.trim(), + type: data.type, + status: "watching", + config: data.config || {}, + createdAt: now.toISOString(), + linkedActionIds: data.linkedActionIds || [], + expiresAt: data.expiresInMs + ? new Date(now.getTime() + data.expiresInMs).toISOString() + : undefined, + }; + + await kv.set(KV.sentinels, sentinel.id, sentinel); + await recordAudit(kv, "sentinel_create", "mem::sentinel-create", [sentinel.id], { + action: "sentinel.create", + type: sentinel.type, + linkedActionIds: sentinel.linkedActionIds, + }); + + if (data.linkedActionIds && data.linkedActionIds.length > 0) { + for (const actionId of data.linkedActionIds) { + const edge: ActionEdge = { + id: generateId("ae"), + type: "gated_by", + sourceActionId: actionId, + targetActionId: sentinel.id, + createdAt: now.toISOString(), + }; + await kv.set(KV.actionEdges, edge.id, edge); + await recordAudit(kv, "sentinel_create", "mem::sentinel-create", [edge.id], { + action: "sentinel.create.edge", + sentinelId: sentinel.id, + sourceActionId: actionId, + }); + } + } + + if (data.type === "timer") { + const durationMs = (data.config as { durationMs: number }).durationMs; + setTimeout(async () => { + try { + await withKeyedLock(`mem:sentinel:${sentinel.id}`, async () => { + const fresh = await kv.get(KV.sentinels, sentinel.id); + if (!fresh || fresh.status !== "watching") return; + fresh.status = "triggered"; + fresh.triggeredAt = new Date().toISOString(); + fresh.result = { reason: "timer_elapsed", durationMs }; + await kv.set(KV.sentinels, fresh.id, fresh); + await recordAudit(kv, "sentinel_trigger", "mem::sentinel-create", [fresh.id], { + action: "sentinel.timer_trigger", + reason: "timer_elapsed", + durationMs, + }); + await unblockLinkedActions(kv, fresh); + }); + } catch (err) { + console.error("sentinel timer callback failed", sentinel.id, err); + } + }, durationMs); + } + + return { success: true, sentinel }; + }, + ); + + sdk.registerFunction("mem::sentinel-trigger", + async (data: { sentinelId: string; result?: unknown }) => { + if (!data.sentinelId) { + return { success: false, error: "sentinelId is required" }; + } + + return withKeyedLock( + `mem:sentinel:${data.sentinelId}`, + async () => { + const sentinel = await kv.get( + KV.sentinels, + data.sentinelId, + ); + if (!sentinel) { + return { success: false, error: "sentinel not found" }; + } + if (sentinel.status !== "watching") { + return { + success: false, + error: `sentinel already ${sentinel.status}`, + }; + } + + sentinel.status = "triggered"; + sentinel.triggeredAt = new Date().toISOString(); + sentinel.result = data.result; + + await kv.set(KV.sentinels, sentinel.id, sentinel); + await recordAudit(kv, "sentinel_trigger", "mem::sentinel-trigger", [sentinel.id], { + action: "sentinel.trigger", + result: data.result, + }); + + let unblockedCount = 0; + if (sentinel.linkedActionIds.length > 0) { + unblockedCount = await unblockLinkedActions(kv, sentinel); + } + + return { success: true, sentinel, unblockedCount }; + }, + ); + }, + ); + + sdk.registerFunction("mem::sentinel-check", + async () => { + const sentinels = await kv.list(KV.sentinels); + const active = sentinels.filter((s) => s.status === "watching"); + const triggered: string[] = []; + + for (const sentinel of active) { + if (sentinel.type === "threshold") { + const cfg = sentinel.config as { + metric: string; + operator: "gt" | "lt" | "eq"; + value: number; + }; + const metrics = await kv.get( + KV.metrics, + cfg.metric, + ); + if (!metrics) continue; + + const current = metrics.totalCalls; + let matched = false; + if (cfg.operator === "gt") matched = current > cfg.value; + else if (cfg.operator === "lt") matched = current < cfg.value; + else if (cfg.operator === "eq") matched = current === cfg.value; + + if (matched) { + await withKeyedLock( + `mem:sentinel:${sentinel.id}`, + async () => { + const fresh = await kv.get( + KV.sentinels, + sentinel.id, + ); + if (!fresh || fresh.status !== "watching") return; + fresh.status = "triggered"; + fresh.triggeredAt = new Date().toISOString(); + fresh.result = { + reason: "threshold_crossed", + metric: cfg.metric, + currentValue: current, + threshold: cfg.value, + operator: cfg.operator, + }; + await kv.set(KV.sentinels, fresh.id, fresh); + await recordAudit(kv, "sentinel_trigger", "mem::sentinel-check", [fresh.id], { + action: "sentinel.threshold_trigger", + result: fresh.result, + }); + await unblockLinkedActions(kv, fresh); + }, + ); + triggered.push(sentinel.id); + } + } + + if (sentinel.type === "pattern") { + const cfg = sentinel.config as { pattern: string }; + const regex = new RegExp(cfg.pattern, "i"); + const sessions = await kv.list(KV.sessions); + let matchedObs: CompressedObservation | null = null; + + for (const session of sessions) { + const observations = await kv.list( + KV.observations(session.id), + ); + const recent = observations + .filter( + (o) => + new Date(o.timestamp).getTime() >= + new Date(sentinel.createdAt).getTime(), + ) + .find((o) => regex.test(o.title)); + if (recent) { + matchedObs = recent; + break; + } + } + + if (matchedObs) { + await withKeyedLock( + `mem:sentinel:${sentinel.id}`, + async () => { + const fresh = await kv.get( + KV.sentinels, + sentinel.id, + ); + if (!fresh || fresh.status !== "watching") return; + fresh.status = "triggered"; + fresh.triggeredAt = new Date().toISOString(); + fresh.result = { + reason: "pattern_matched", + pattern: cfg.pattern, + matchedObservationId: matchedObs!.id, + matchedTitle: matchedObs!.title, + }; + await kv.set(KV.sentinels, fresh.id, fresh); + await recordAudit(kv, "sentinel_trigger", "mem::sentinel-check", [fresh.id], { + action: "sentinel.pattern_trigger", + result: fresh.result, + }); + await unblockLinkedActions(kv, fresh); + }, + ); + triggered.push(sentinel.id); + } + } + } + + return { success: true, triggered, checkedCount: active.length }; + }, + ); + + sdk.registerFunction("mem::sentinel-cancel", + async (data: { sentinelId: string }) => { + if (!data.sentinelId) { + return { success: false, error: "sentinelId is required" }; + } + + return withKeyedLock( + `mem:sentinel:${data.sentinelId}`, + async () => { + const sentinel = await kv.get( + KV.sentinels, + data.sentinelId, + ); + if (!sentinel) { + return { success: false, error: "sentinel not found" }; + } + if (sentinel.status !== "watching") { + return { + success: false, + error: `cannot cancel sentinel with status ${sentinel.status}`, + }; + } + + sentinel.status = "cancelled"; + await kv.set(KV.sentinels, sentinel.id, sentinel); + await recordAudit(kv, "sentinel_trigger", "mem::sentinel-cancel", [sentinel.id], { + action: "sentinel.cancel", + status: "cancelled", + }); + + return { success: true, sentinel }; + }, + ); + }, + ); + + sdk.registerFunction("mem::sentinel-list", + async (data: { status?: string; type?: string }) => { + let sentinels = await kv.list(KV.sentinels); + + if (data.status) { + sentinels = sentinels.filter((s) => s.status === data.status); + } + if (data.type) { + sentinels = sentinels.filter((s) => s.type === data.type); + } + + sentinels.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + return { success: true, sentinels }; + }, + ); + + sdk.registerFunction("mem::sentinel-expire", + async () => { + const sentinels = await kv.list(KV.sentinels); + const now = Date.now(); + let expired = 0; + + for (const sentinel of sentinels) { + if ( + sentinel.status === "watching" && + sentinel.expiresAt && + new Date(sentinel.expiresAt).getTime() <= now + ) { + const didExpire = await withKeyedLock( + `mem:sentinel:${sentinel.id}`, + async () => { + const fresh = await kv.get( + KV.sentinels, + sentinel.id, + ); + if (!fresh || fresh.status !== "watching") return false; + fresh.status = "expired"; + fresh.triggeredAt = new Date().toISOString(); + await kv.set(KV.sentinels, fresh.id, fresh); + await recordAudit(kv, "sentinel_trigger", "mem::sentinel-expire", [fresh.id], { + action: "sentinel.expire", + status: "expired", + }); + return true; + }, + ); + if (didExpire) expired++; + } + } + + return { success: true, expired }; + }, + ); +} + +async function unblockLinkedActions( + kv: StateKV, + sentinel: Sentinel, +): Promise { + if (sentinel.linkedActionIds.length === 0) return 0; + + const allEdges = await kv.list(KV.actionEdges); + const allSentinels = await kv.list(KV.sentinels); + const allCheckpoints = await kv.list(KV.checkpoints); + const gateMap = new Map(); + for (const s of allSentinels) gateMap.set(s.id, { status: s.status === "triggered" ? "passed" : s.status }); + for (const c of allCheckpoints) gateMap.set(c.id, { status: c.status }); + + let unblockedCount = 0; + + for (const actionId of sentinel.linkedActionIds) { + await withKeyedLock(`mem:action:${actionId}`, async () => { + const action = await kv.get(KV.actions, actionId); + if (action && action.status === "blocked") { + const gates = allEdges.filter( + (e) => e.sourceActionId === actionId && e.type === "gated_by", + ); + const allPassed = gates.every((g) => { + const gate = gateMap.get(g.targetActionId); + return gate && gate.status === "passed"; + }); + if (allPassed) { + action.status = "pending"; + action.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, action.id, action); + await recordAudit(kv, "action_update", "mem::sentinel-unblock", [action.id], { + action: "action.unblocked", + sentinelId: sentinel.id, + }); + unblockedCount++; + } + } + }); + } + + return unblockedCount; +} diff --git a/src/functions/signals.ts b/src/functions/signals.ts new file mode 100644 index 0000000..6abf746 --- /dev/null +++ b/src/functions/signals.ts @@ -0,0 +1,201 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import type { Signal } from "../types.js"; +import { recordAudit } from "./audit.js"; + +export function registerSignalsFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::signal-send", + async (data: { + from: string; + to?: string; + content: string; + type?: Signal["type"]; + threadId?: string; + replyTo?: string; + metadata?: Record; + expiresInMs?: number; + }) => { + if (!data.from?.trim() || !data.content?.trim()) { + return { success: false, error: "from and non-empty content are required" }; + } + + const now = new Date(); + let threadId = data.threadId; + + if (data.replyTo && !threadId) { + const parent = await kv.get(KV.signals, data.replyTo); + if (parent) { + threadId = parent.threadId || parent.id; + } + } + + const signal: Signal = { + id: generateId("sig"), + from: data.from, + to: data.to, + content: data.content.trim(), + type: data.type || "info", + threadId: threadId || generateId("thr"), + replyTo: data.replyTo, + metadata: data.metadata, + createdAt: now.toISOString(), + expiresAt: data.expiresInMs + ? new Date(now.getTime() + data.expiresInMs).toISOString() + : undefined, + }; + + await kv.set(KV.signals, signal.id, signal); + await recordAudit(kv, "signal_send", "mem::signal-send", [signal.id], { + action: "create", + from: data.from, + to: data.to, + type: signal.type, + }); + + return { success: true, signal }; + }, + ); + + sdk.registerFunction("mem::signal-read", + async (data: { + agentId: string; + unreadOnly?: boolean; + threadId?: string; + type?: string; + limit?: number; + }) => { + if (!data.agentId) { + return { success: false, error: "agentId is required" }; + } + + let signals = await kv.list(KV.signals); + const now = Date.now(); + + signals = signals.filter((s) => { + if (s.expiresAt && new Date(s.expiresAt).getTime() <= now) return false; + if (s.to && s.to !== data.agentId && s.from !== data.agentId) + return false; + if (!s.to && s.from !== data.agentId) return true; + return true; + }); + + if (data.unreadOnly) { + signals = signals.filter((s) => !s.readAt && s.to === data.agentId); + } + if (data.threadId) { + signals = signals.filter((s) => s.threadId === data.threadId); + } + if (data.type) { + signals = signals.filter((s) => s.type === data.type); + } + + signals.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + const limit = data.limit || 50; + const results = signals.slice(0, limit); + + for (const sig of results) { + if (!sig.readAt && sig.to === data.agentId) { + const beforeReadAt = sig.readAt; + sig.readAt = new Date().toISOString(); + await recordAudit(kv, "signal_send", "mem::signal-read", [sig.id], { + action: "signal.mark_read", + actor: data.agentId, + beforeReadAt, + afterReadAt: sig.readAt, + }); + await kv.set(KV.signals, sig.id, sig); + } + } + + return { success: true, signals: results }; + }, + ); + + sdk.registerFunction("mem::signal-threads", + async (data: { agentId: string; limit?: number }) => { + if (!data.agentId) { + return { success: false, error: "agentId is required" }; + } + + const signals = await kv.list(KV.signals); + const now = Date.now(); + + const relevant = signals.filter((s) => { + if (s.expiresAt && new Date(s.expiresAt).getTime() <= now) return false; + return ( + s.from === data.agentId || + s.to === data.agentId || + !s.to + ); + }); + + const threadMap = new Map< + string, + { threadId: string; messages: number; lastMessage: string; participants: Set } + >(); + + for (const sig of relevant) { + const tid = sig.threadId || sig.id; + const existing = threadMap.get(tid); + if (existing) { + existing.messages++; + existing.participants.add(sig.from); + if (sig.to) existing.participants.add(sig.to); + if (new Date(sig.createdAt) > new Date(existing.lastMessage)) { + existing.lastMessage = sig.createdAt; + } + } else { + const participants = new Set([sig.from]); + if (sig.to) participants.add(sig.to); + threadMap.set(tid, { + threadId: tid, + messages: 1, + lastMessage: sig.createdAt, + participants, + }); + } + } + + const threads = Array.from(threadMap.values()) + .map((t) => ({ + ...t, + participants: Array.from(t.participants), + })) + .sort( + (a, b) => + new Date(b.lastMessage).getTime() - + new Date(a.lastMessage).getTime(), + ) + .slice(0, data.limit || 20); + + return { success: true, threads }; + }, + ); + + sdk.registerFunction("mem::signal-cleanup", + async () => { + const signals = await kv.list(KV.signals); + const now = Date.now(); + let removed = 0; + + for (const sig of signals) { + if (sig.expiresAt && new Date(sig.expiresAt).getTime() <= now) { + await recordAudit(kv, "delete", "mem::signal-cleanup", [sig.id], { + action: "delete", + resource: "Signal", + before: sig, + }); + await kv.delete(KV.signals, sig.id); + removed++; + } + } + + return { success: true, removed }; + }, + ); +} diff --git a/src/functions/sketches.ts b/src/functions/sketches.ts new file mode 100644 index 0000000..c617a29 --- /dev/null +++ b/src/functions/sketches.ts @@ -0,0 +1,315 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV, generateId } from "../state/schema.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import type { Action, ActionEdge, Sketch } from "../types.js"; +import { safeAudit } from "./audit.js"; + +export function registerSketchesFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::sketch-create", + async (data: { + title: string; + description?: string; + expiresInMs?: number; + project?: string; + }) => { + if (!data.title || typeof data.title !== "string") { + return { success: false, error: "title is required" }; + } + + const now = new Date(); + const expiresInMs = data.expiresInMs || 3600000; + const sketch: Sketch = { + id: generateId("sk"), + title: data.title.trim(), + description: (data.description || "").trim(), + status: "active", + actionIds: [], + project: data.project, + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + expiresInMs).toISOString(), + }; + + await kv.set(KV.sketches, sketch.id, sketch); + await safeAudit(kv, "sketch_create", "mem::sketch-create", [sketch.id], { + action: "create", + title: sketch.title, + }); + return { success: true, sketch }; + }, + ); + + sdk.registerFunction("mem::sketch-add", + async (data: { + sketchId: string; + title: string; + description?: string; + priority?: number; + dependsOn?: string[]; + }) => { + if (!data.sketchId) { + return { success: false, error: "sketchId is required" }; + } + if (!data.title || typeof data.title !== "string") { + return { success: false, error: "title is required" }; + } + + return withKeyedLock(`mem:sketch:${data.sketchId}`, async () => { + const sketch = await kv.get(KV.sketches, data.sketchId); + if (!sketch) { + return { success: false, error: "sketch not found" }; + } + if (sketch.status !== "active") { + return { success: false, error: "sketch is not active" }; + } + + const now = new Date().toISOString(); + const action: Action = { + id: generateId("act"), + title: data.title.trim(), + description: (data.description || "").trim(), + status: "pending", + priority: Math.max(1, Math.min(10, data.priority || 5)), + createdAt: now, + updatedAt: now, + createdBy: "sketch", + project: sketch.project, + tags: [], + sourceObservationIds: [], + sourceMemoryIds: [], + sketchId: data.sketchId, + }; + + if (data.dependsOn && data.dependsOn.length > 0) { + const sketchActionSet = new Set(sketch.actionIds); + for (const depId of data.dependsOn) { + if (!sketchActionSet.has(depId)) { + return { + success: false, + error: `dependency ${depId} not found in this sketch`, + }; + } + } + } + + await kv.set(KV.actions, action.id, action); + await safeAudit(kv, "sketch_create", "mem::sketch-add", [action.id], { + action: "add.action", + sketchId: sketch.id, + }); + + const createdEdges: ActionEdge[] = []; + if (data.dependsOn && data.dependsOn.length > 0) { + for (const depId of data.dependsOn) { + const edge: ActionEdge = { + id: generateId("ae"), + type: "requires", + sourceActionId: action.id, + targetActionId: depId, + createdAt: now, + }; + await kv.set(KV.actionEdges, edge.id, edge); + await safeAudit(kv, "sketch_create", "mem::sketch-add", [edge.id], { + action: "add.edge", + sketchId: sketch.id, + }); + createdEdges.push(edge); + } + } + + sketch.actionIds.push(action.id); + await kv.set(KV.sketches, sketch.id, sketch); + await safeAudit(kv, "sketch_create", "mem::sketch-add", [sketch.id], { + action: "add.sketch-update", + addedActionId: action.id, + }); + + return { success: true, action, edges: createdEdges }; + }); + }, + ); + + sdk.registerFunction("mem::sketch-promote", + async (data: { sketchId: string; project?: string }) => { + if (!data.sketchId) { + return { success: false, error: "sketchId is required" }; + } + + return withKeyedLock(`mem:sketch:${data.sketchId}`, async () => { + const sketch = await kv.get(KV.sketches, data.sketchId); + if (!sketch) { + return { success: false, error: "sketch not found" }; + } + if (sketch.status !== "active") { + return { success: false, error: "sketch is not active" }; + } + + const promotedIds: string[] = []; + for (const actionId of sketch.actionIds) { + const action = await kv.get(KV.actions, actionId); + if (action) { + delete action.sketchId; + if (data.project) { + action.project = data.project; + } + action.updatedAt = new Date().toISOString(); + await kv.set(KV.actions, action.id, action); + await safeAudit(kv, "sketch_promote", "mem::sketch-promote", [action.id], { + action: "promote.action", + sketchId: sketch.id, + }); + promotedIds.push(action.id); + } + } + + sketch.status = "promoted"; + sketch.promotedAt = new Date().toISOString(); + await kv.set(KV.sketches, sketch.id, sketch); + await safeAudit(kv, "sketch_promote", "mem::sketch-promote", [sketch.id], { + action: "promote.sketch", + promotedIds, + }); + + return { success: true, promotedIds }; + }); + }, + ); + + sdk.registerFunction("mem::sketch-discard", + async (data: { sketchId: string }) => { + if (!data.sketchId) { + return { success: false, error: "sketchId is required" }; + } + + return withKeyedLock(`mem:sketch:${data.sketchId}`, async () => { + const sketch = await kv.get(KV.sketches, data.sketchId); + if (!sketch) { + return { success: false, error: "sketch not found" }; + } + if (sketch.status !== "active") { + return { success: false, error: "sketch is not active" }; + } + + const actionIdSet = new Set(sketch.actionIds); + + const allEdges = await kv.list(KV.actionEdges); + for (const edge of allEdges) { + if ( + actionIdSet.has(edge.sourceActionId) || + actionIdSet.has(edge.targetActionId) + ) { + await kv.delete(KV.actionEdges, edge.id); + await safeAudit(kv, "sketch_discard", "mem::sketch-discard", [edge.id], { + action: "discard.edge", + sketchId: sketch.id, + }); + } + } + + for (const actionId of sketch.actionIds) { + await kv.delete(KV.actions, actionId); + await safeAudit(kv, "sketch_discard", "mem::sketch-discard", [actionId], { + action: "discard.action", + sketchId: sketch.id, + }); + } + + sketch.status = "discarded"; + sketch.discardedAt = new Date().toISOString(); + await kv.set(KV.sketches, sketch.id, sketch); + await safeAudit(kv, "sketch_discard", "mem::sketch-discard", [sketch.id], { + action: "discard.sketch", + }); + + return { success: true, discardedCount: sketch.actionIds.length }; + }); + }, + ); + + sdk.registerFunction("mem::sketch-list", + async (data: { status?: string; project?: string }) => { + let sketches = await kv.list(KV.sketches); + + if (data.status) { + sketches = sketches.filter((s) => s.status === data.status); + } + if (data.project) { + sketches = sketches.filter((s) => s.project === data.project); + } + + sketches.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + const results = sketches.map((s) => ({ + ...s, + actionCount: s.actionIds.length, + })); + + return { success: true, sketches: results }; + }, + ); + + sdk.registerFunction("mem::sketch-gc", + async () => { + const sketches = await kv.list(KV.sketches); + const now = Date.now(); + let collected = 0; + + for (const sketch of sketches) { + if ( + sketch.status !== "active" || + new Date(sketch.expiresAt).getTime() > now + ) { + continue; + } + + await withKeyedLock(`mem:sketch:${sketch.id}`, async () => { + const current = await kv.get(KV.sketches, sketch.id); + if ( + !current || + current.status !== "active" || + new Date(current.expiresAt).getTime() > now + ) { + return; + } + + const actionIdSet = new Set(current.actionIds); + + const allEdges = await kv.list(KV.actionEdges); + for (const edge of allEdges) { + if ( + actionIdSet.has(edge.sourceActionId) || + actionIdSet.has(edge.targetActionId) + ) { + await kv.delete(KV.actionEdges, edge.id); + await safeAudit(kv, "sketch_discard", "mem::sketch-gc", [edge.id], { + action: "gc.edge", + sketchId: current.id, + }); + } + } + + for (const actionId of current.actionIds) { + await kv.delete(KV.actions, actionId); + await safeAudit(kv, "sketch_discard", "mem::sketch-gc", [actionId], { + action: "gc.action", + sketchId: current.id, + }); + } + + current.status = "discarded"; + current.discardedAt = new Date().toISOString(); + await kv.set(KV.sketches, current.id, current); + await safeAudit(kv, "sketch_discard", "mem::sketch-gc", [current.id], { + action: "gc.sketch", + }); + collected++; + }); + } + + return { success: true, collected }; + }, + ); +} diff --git a/src/functions/skill-extract.ts b/src/functions/skill-extract.ts new file mode 100644 index 0000000..d3bbbd3 --- /dev/null +++ b/src/functions/skill-extract.ts @@ -0,0 +1,290 @@ +import type { ISdk } from "iii-sdk"; +import type { + CompressedObservation, + SessionSummary, + ProceduralMemory, + Session, + MemoryProvider, +} from "../types.js"; +import { KV, generateId, fingerprintId } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +const SKILL_EXTRACT_SYSTEM = `You are a skill extraction engine. Given a completed multi-step task session, extract a reusable procedural skill document. + +Output format: + +When the agent encounters [specific situation/pattern] +Short skill title + +First concrete action +Second concrete action + +What success looks like +comma,separated,tags + + +Rules: +- Extract ONLY if the session shows a clear multi-step procedure that succeeded +- Steps must be concrete and actionable, not vague +- The trigger should describe WHEN to apply this skill +- If the session is exploratory with no clear procedure, output +- Maximum 10 steps per skill`; + +function buildSkillPrompt( + summary: SessionSummary, + observations: CompressedObservation[], +): string { + const obsText = observations + .filter((o) => o.importance >= 4) + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + .slice(0, 30) + .map( + (o) => + `[${o.type}] ${o.title}${o.narrative ? ": " + o.narrative : ""}`, + ) + .join("\n"); + + return `## Session Summary +Title: ${summary.title} +Narrative: ${summary.narrative} +Key Decisions: ${summary.keyDecisions.join("; ")} +Files Modified: ${summary.filesModified.join(", ")} +Concepts: ${summary.concepts.join(", ")} + +## Observations (${observations.length} total, showing top by importance) +${obsText}`; +} + +function parseSkillXml( + xml: string, +): { + trigger: string; + title: string; + steps: string[]; + expectedOutcome: string; + tags: string[]; +} | null { + if (xml.includes("")) return null; + + const triggerMatch = xml.match(/([\s\S]*?)<\/trigger>/); + const titleMatch = xml.match(/([\s\S]*?)<\/title>/); + const stepsMatch = xml.match(/<steps>([\s\S]*?)<\/steps>/); + const outcomeMatch = xml.match( + /<expected_outcome>([\s\S]*?)<\/expected_outcome>/, + ); + const tagsMatch = xml.match(/<tags>([\s\S]*?)<\/tags>/); + + if (!triggerMatch || !titleMatch || !stepsMatch) return null; + + const stepRegex = /<step>([\s\S]*?)<\/step>/g; + const steps: string[] = []; + let match; + while ((match = stepRegex.exec(stepsMatch[1])) !== null) { + const step = match[1].trim(); + if (step) steps.push(step); + } + + if (steps.length < 2) return null; + + return { + trigger: triggerMatch[1].trim(), + title: titleMatch[1].trim(), + steps, + expectedOutcome: outcomeMatch?.[1]?.trim() || "", + tags: tagsMatch?.[1] + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean) || [], + }; +} + +export function registerSkillExtractFunctions( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::skill-extract", + async (data: { sessionId: string }) => { + if (!data?.sessionId) { + return { success: false, error: "sessionId is required" }; + } + + const session = await kv + .get<Session>(KV.sessions, data.sessionId) + .catch(() => null); + if (!session) { + return { success: false, error: "session not found" }; + } + if (session.status !== "completed") { + return { + success: false, + error: "session must be completed before skill extraction", + }; + } + + const [summary, observations] = await Promise.all([ + kv.get<SessionSummary>(KV.summaries, data.sessionId).catch(() => null), + kv.list<CompressedObservation>(KV.observations(data.sessionId)).catch(() => []), + ]); + if (!summary) { + return { + success: false, + error: "no summary — run mem::summarize first", + }; + } + if (observations.length < 3) { + return { success: false, error: "too few observations for skill extraction" }; + } + + try { + const prompt = buildSkillPrompt(summary, observations); + const response = await provider.summarize( + SKILL_EXTRACT_SYSTEM, + prompt, + ); + const parsed = parseSkillXml(response); + + if (!parsed) { + logger.info("No skill extracted — session was exploratory", { + sessionId: data.sessionId, + }); + return { success: true, extracted: false, reason: "no clear procedure found" }; + } + + const fp = fingerprintId( + "skill", + JSON.stringify({ + title: parsed.title.toLowerCase(), + trigger: parsed.trigger.toLowerCase(), + steps: parsed.steps.map((s) => s.toLowerCase().trim()), + }), + ); + const existing = await kv + .get<ProceduralMemory>(KV.procedural, fp) + .catch(() => null); + + if (existing) { + const alreadyReinforced = existing.sourceSessionIds.includes(data.sessionId); + if (!alreadyReinforced) { + existing.strength = Math.min(1.0, existing.strength + 0.15); + existing.frequency++; + existing.sourceSessionIds = [...existing.sourceSessionIds, data.sessionId]; + } + existing.updatedAt = new Date().toISOString(); + await kv.set(KV.procedural, existing.id, existing); + + try { + await recordAudit(kv, "skill_extract", "mem::skill-extract", [], { + skillId: existing.id, + reinforced: true, + sessionId: data.sessionId, + }); + } catch {} + + logger.info("Skill reinforced", { + id: existing.id, + name: parsed.title, + }); + return { + success: true, + extracted: true, + reinforced: true, + skill: existing, + }; + } + + const now = new Date().toISOString(); + const skill: ProceduralMemory = { + id: fp, + name: parsed.title, + triggerCondition: parsed.trigger, + steps: parsed.steps, + expectedOutcome: parsed.expectedOutcome, + strength: 0.6, + frequency: 1, + tags: parsed.tags, + concepts: summary.concepts, + sourceSessionIds: [data.sessionId], + sourceObservationIds: observations + .slice(0, 10) + .map((o) => o.id), + createdAt: now, + updatedAt: now, + }; + + await kv.set(KV.procedural, skill.id, skill); + + try { + await recordAudit(kv, "skill_extract", "mem::skill-extract", [], { + skillId: skill.id, + title: parsed.title, + steps: parsed.steps.length, + sessionId: data.sessionId, + }); + } catch {} + + logger.info("Skill extracted", { + id: skill.id, + title: parsed.title, + steps: parsed.steps.length, + }); + + return { success: true, extracted: true, reinforced: false, skill }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Skill extraction failed", { error: msg }); + return { success: false, error: msg }; + } + }, + ); + + sdk.registerFunction("mem::skill-list", + async (data: { limit?: number }) => { + const limit = data?.limit ?? 50; + const skills = await kv.list<ProceduralMemory>(KV.procedural); + const sorted = skills.sort((a, b) => b.strength - a.strength); + return { + success: true, + skills: sorted.slice(0, limit), + total: sorted.length, + }; + }, + ); + + sdk.registerFunction("mem::skill-match", + async (data: { query: string; limit?: number }) => { + if (!data?.query?.trim()) { + return { success: false, error: "query is required" }; + } + + const limit = data.limit ?? 5; + const query = data.query.toLowerCase(); + const terms = query.split(/\s+/).filter((t) => t.length > 2); + + const skills = await kv.list<ProceduralMemory>(KV.procedural); + + const scored = skills + .map((skill) => { + const text = + `${skill.name} ${skill.triggerCondition} ${(skill.tags || []).join(" ")} ${skill.steps.join(" ")}`.toLowerCase(); + const matchCount = terms.filter((t) => text.includes(t)).length; + if (matchCount === 0) return null; + const relevance = matchCount / terms.length; + return { skill, score: relevance * skill.strength }; + }) + .filter(Boolean) as Array<{ + skill: ProceduralMemory; + score: number; + }>; + + scored.sort((a, b) => b.score - a.score); + + return { + success: true, + matches: scored.slice(0, limit), + }; + }, + ); +} diff --git a/src/functions/sliding-window.ts b/src/functions/sliding-window.ts new file mode 100644 index 0000000..1de03df --- /dev/null +++ b/src/functions/sliding-window.ts @@ -0,0 +1,267 @@ +import type { ISdk } from "iii-sdk"; +import type { + CompressedObservation, + EnrichedChunk, + MemoryProvider, +} from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +const SLIDING_WINDOW_SYSTEM = `You are a contextual enrichment engine. Given a primary observation and its surrounding context window (previous and next observations from the same session), produce an enriched version. + +Your tasks: +1. ENTITY RESOLUTION: Replace all pronouns, implicit references ("that framework", "the file", "it", "he/she") with the explicit entity names found in the context window. +2. PREFERENCE MAPPING: Extract any user preferences, constraints, or opinions expressed directly or indirectly. +3. CONTEXT BRIDGES: Add brief contextual links that make this chunk self-contained without reading adjacent chunks. + +Output EXACTLY this XML: +<enriched> + <content>The fully enriched, self-contained text with all references resolved</content> + <resolved_entities> + <entity original="pronoun or reference" resolved="explicit entity name"/> + </resolved_entities> + <preferences> + <preference>extracted user preference or constraint</preference> + </preferences> + <context_bridges> + <bridge>contextual link to adjacent information</bridge> + </context_bridges> +</enriched> + +Rules: +- The enriched content MUST be understandable in complete isolation +- Resolve ALL ambiguous references using the context window +- Do not hallucinate entities not present in the window +- Preserve factual accuracy while adding clarity`; + +function buildWindowPrompt( + primary: CompressedObservation, + before: CompressedObservation[], + after: CompressedObservation[], +): string { + const parts: string[] = []; + + if (before.length > 0) { + parts.push("=== PRECEDING CONTEXT ==="); + for (const obs of before) { + parts.push(`[${obs.type}] ${obs.title}: ${obs.narrative}`); + if (obs.facts.length > 0) parts.push(`Facts: ${obs.facts.join("; ")}`); + if (obs.concepts.length > 0) + parts.push(`Concepts: ${obs.concepts.join(", ")}`); + } + } + + parts.push("\n=== PRIMARY OBSERVATION (enrich this) ==="); + parts.push(`Type: ${primary.type}`); + parts.push(`Title: ${primary.title}`); + if (primary.subtitle) parts.push(`Subtitle: ${primary.subtitle}`); + parts.push(`Narrative: ${primary.narrative}`); + if (primary.facts.length > 0) + parts.push(`Facts: ${primary.facts.join("; ")}`); + if (primary.concepts.length > 0) + parts.push(`Concepts: ${primary.concepts.join(", ")}`); + if (primary.files.length > 0) + parts.push(`Files: ${primary.files.join(", ")}`); + + if (after.length > 0) { + parts.push("\n=== FOLLOWING CONTEXT ==="); + for (const obs of after) { + parts.push(`[${obs.type}] ${obs.title}: ${obs.narrative}`); + if (obs.facts.length > 0) parts.push(`Facts: ${obs.facts.join("; ")}`); + } + } + + return parts.join("\n"); +} + +function parseEnrichedXml(xml: string): { + content: string; + resolvedEntities: Record<string, string>; + preferences: string[]; + contextBridges: string[]; +} | null { + const contentMatch = xml.match(/<content>([\s\S]*?)<\/content>/); + if (!contentMatch) return null; + + const resolvedEntities: Record<string, string> = {}; + const entityRegex = + /<entity\s+original="([^"]+)"\s+resolved="([^"]+)"\s*\/>/g; + let match; + while ((match = entityRegex.exec(xml)) !== null) { + resolvedEntities[match[1]] = match[2]; + } + + const preferences: string[] = []; + const prefRegex = /<preference>([^<]+)<\/preference>/g; + while ((match = prefRegex.exec(xml)) !== null) { + preferences.push(match[1]); + } + + const contextBridges: string[] = []; + const bridgeRegex = /<bridge>([^<]+)<\/bridge>/g; + while ((match = bridgeRegex.exec(xml)) !== null) { + contextBridges.push(match[1]); + } + + return { + content: contentMatch[1].trim(), + resolvedEntities, + preferences, + contextBridges, + }; +} + +export function registerSlidingWindowFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::enrich-window", + async (data: { + observationId: string; + sessionId: string; + lookback?: number; + lookahead?: number; + }) => { + if ( + !data || + typeof data.sessionId !== "string" || + !data.sessionId.trim() || + typeof data.observationId !== "string" || + !data.observationId.trim() + ) { + return { success: false, error: "sessionId and observationId are required" }; + } + const sessionId = data.sessionId.trim(); + const observationId = data.observationId.trim(); + const hprev = data.lookback ?? 3; + const hnext = data.lookahead ?? 2; + + const allObs = await kv.list<CompressedObservation>( + KV.observations(sessionId), + ); + allObs.sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + const primaryIdx = allObs.findIndex((o) => o.id === observationId); + if (primaryIdx === -1) { + return { success: false, error: "Observation not found" }; + } + + const primary = allObs[primaryIdx]; + const before = allObs.slice(Math.max(0, primaryIdx - hprev), primaryIdx); + const after = allObs.slice(primaryIdx + 1, primaryIdx + 1 + hnext); + + if (before.length === 0 && after.length === 0) { + return { + success: true, + enriched: null, + reason: "No adjacent context available", + }; + } + + try { + const prompt = buildWindowPrompt(primary, before, after); + const response = await provider.compress( + SLIDING_WINDOW_SYSTEM, + prompt, + ); + const parsed = parseEnrichedXml(response); + + if (!parsed) { + logger.warn("Failed to parse enrichment XML", { + obsId: data.observationId, + }); + return { success: false, error: "parse_failed" }; + } + + const enriched: EnrichedChunk = { + id: generateId("ec"), + originalObsId: observationId, + sessionId, + content: parsed.content, + resolvedEntities: parsed.resolvedEntities, + preferences: parsed.preferences, + contextBridges: parsed.contextBridges, + windowStart: Math.max(0, primaryIdx - hprev), + windowEnd: Math.min(allObs.length - 1, primaryIdx + hnext), + createdAt: new Date().toISOString(), + }; + + await kv.set( + KV.enrichedChunks(sessionId), + observationId, + enriched, + ); + await recordAudit(kv, "observe", "mem::enrich-window", [enriched.id], { + action: "persist_enriched_chunk", + sessionId, + observationId, + }); + + logger.info("Observation enriched via sliding window", { + obsId: observationId, + entitiesResolved: Object.keys(parsed.resolvedEntities).length, + preferencesFound: parsed.preferences.length, + bridges: parsed.contextBridges.length, + }); + + return { success: true, enriched }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Sliding window enrichment failed", { error: msg }); + return { success: false, error: msg }; + } + }, + ); + + sdk.registerFunction("mem::enrich-session", + async (data: { + sessionId: string; + lookback?: number; + lookahead?: number; + minImportance?: number; + }) => { + if (!data || typeof data.sessionId !== "string" || !data.sessionId.trim()) { + return { success: false, error: "sessionId is required" }; + } + const sessionId = data.sessionId.trim(); + const allObs = await kv.list<CompressedObservation>( + KV.observations(sessionId), + ); + const minImp = data.minImportance ?? 4; + const toEnrich = allObs.filter((o) => o.importance >= minImp); + + let enriched = 0; + let failed = 0; + + for (const obs of toEnrich) { + try { + const result = (await sdk.trigger({ function_id: "mem::enrich-window", payload: { + observationId: obs.id, + sessionId, + lookback: data.lookback ?? 3, + lookahead: data.lookahead ?? 2, + } })) as { success?: boolean } | undefined; + if (result?.success) enriched++; + else failed++; + } catch { + failed++; + } + } + + logger.info("Session enrichment complete", { + sessionId, + total: toEnrich.length, + enriched, + failed, + }); + + return { success: true, total: toEnrich.length, enriched, failed }; + }, + ); +} diff --git a/src/functions/slots.ts b/src/functions/slots.ts new file mode 100644 index 0000000..47b49d4 --- /dev/null +++ b/src/functions/slots.ts @@ -0,0 +1,487 @@ +import type { ISdk } from "iii-sdk"; +import type { MemorySlot, CompressedObservation } from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { recordAudit } from "./audit.js"; +import { getEnvVar } from "../config.js"; +import { logger } from "../logger.js"; + +type SlotScope = "project" | "global"; + +const DEFAULT_SIZE_LIMIT = 2000; + +export const DEFAULT_SLOTS: ReadonlyArray< + Omit<MemorySlot, "createdAt" | "updatedAt"> +> = [ + { + label: "persona", + content: "", + sizeLimit: 1000, + description: + "How the agent should see itself: role, tone, behavioural guidelines.", + pinned: true, + readOnly: false, + scope: "global", + }, + { + label: "user_preferences", + content: "", + sizeLimit: 2000, + description: + "Coding style, tool preferences, naming conventions, and other habits the user wants preserved across sessions.", + pinned: true, + readOnly: false, + scope: "global", + }, + { + label: "tool_guidelines", + content: "", + sizeLimit: 1500, + description: + "Rules the agent should follow when picking or sequencing tools (e.g. prefer X over Y, never run Z without confirmation).", + pinned: true, + readOnly: false, + scope: "global", + }, + { + label: "project_context", + content: "", + sizeLimit: 3000, + description: + "Architecture decisions, codebase conventions, build/test commands, and cross-cutting constraints for the current project.", + pinned: true, + readOnly: false, + scope: "project", + }, + { + label: "guidance", + content: "", + sizeLimit: 1500, + description: + "Active advice for the next session: what to focus on, what to avoid, open risks.", + pinned: true, + readOnly: false, + scope: "project", + }, + { + label: "pending_items", + content: "", + sizeLimit: 2000, + description: + "Unfinished work, explicit TODOs, and promises made but not yet delivered.", + pinned: true, + readOnly: false, + scope: "project", + }, + { + label: "session_patterns", + content: "", + sizeLimit: 1500, + description: + "Recurring behaviours and common struggles observed across recent sessions.", + pinned: false, + readOnly: false, + scope: "project", + }, + { + label: "self_notes", + content: "", + sizeLimit: 1500, + description: + "Free-form notes the agent keeps for itself: hypotheses, dead ends, things to revisit.", + pinned: false, + readOnly: false, + scope: "project", + }, +]; + +// Read merged env so values loaded from ~/.agentmemory/.env are +// honoured. process.env alone misses .env-only exports (#678). +export function isSlotsEnabled(): boolean { + return getEnvVar("AGENTMEMORY_SLOTS") === "true"; +} + +export function isReflectEnabled(): boolean { + return getEnvVar("AGENTMEMORY_REFLECT") === "true"; +} + +function scopeKv(scope: SlotScope): string { + return scope === "global" ? KV.globalSlots : KV.slots; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function validateLabel(label: unknown): string | null { + if (typeof label !== "string") return null; + const trimmed = label.trim(); + if (!trimmed || trimmed.length > 64) return null; + if (!/^[a-z][a-z0-9_]*$/.test(trimmed)) return null; + return trimmed; +} + +async function readSlot( + kv: StateKV, + label: string, +): Promise<{ slot: MemorySlot | null; scope: SlotScope }> { + const project = await kv.get<MemorySlot>(KV.slots, label); + if (project) return { slot: project, scope: "project" }; + const global = await kv.get<MemorySlot>(KV.globalSlots, label); + if (global) return { slot: global, scope: "global" }; + return { slot: null, scope: "project" }; +} + +async function readSlotInScope( + kv: StateKV, + label: string, + scope: SlotScope, +): Promise<MemorySlot | null> { + return kv.get<MemorySlot>(scopeKv(scope), label); +} + +function validateScope(raw: unknown): SlotScope | null { + if (raw === undefined || raw === null) return "project"; + if (raw === "project" || raw === "global") return raw; + return null; +} + +function validateSizeLimit(raw: unknown): number | null | undefined { + if (raw === undefined || raw === null) return DEFAULT_SIZE_LIMIT; + if (typeof raw !== "number") return null; + if (!Number.isInteger(raw) || raw < 1 || raw > 20000) return null; + return raw; +} + +async function seedDefaults(kv: StateKV): Promise<void> { + const ts = nowIso(); + for (const tmpl of DEFAULT_SLOTS) { + const target = scopeKv(tmpl.scope); + const existing = await kv.get<MemorySlot>(target, tmpl.label); + if (existing) continue; + const slot: MemorySlot = { + ...tmpl, + createdAt: ts, + updatedAt: ts, + }; + await kv.set(target, tmpl.label, slot); + } +} + +export async function listPinnedSlots(kv: StateKV): Promise<MemorySlot[]> { + const [project, global] = await Promise.all([ + kv.list<MemorySlot>(KV.slots), + kv.list<MemorySlot>(KV.globalSlots), + ]); + const merged = new Map<string, MemorySlot>(); + for (const s of global) merged.set(s.label, s); + for (const s of project) merged.set(s.label, s); + return Array.from(merged.values()) + .filter((s) => s.pinned && s.content.trim().length > 0) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +export function renderPinnedContext(slots: MemorySlot[]): string { + if (slots.length === 0) return ""; + const lines: string[] = ["# agentmemory pinned slots", ""]; + for (const slot of slots) { + lines.push(`## ${slot.label}`); + lines.push(slot.content.trim()); + lines.push(""); + } + return lines.join("\n"); +} + +export function registerSlotsFunctions(sdk: ISdk, kv: StateKV): void { + void seedDefaults(kv).catch((err) => { + logger.warn("slot defaults seed failed", { + error: err instanceof Error ? err.message : String(err), + }); + }); + + sdk.registerFunction("mem::slot-list", async () => { + const [project, global] = await Promise.all([ + kv.list<MemorySlot>(KV.slots), + kv.list<MemorySlot>(KV.globalSlots), + ]); + const merged = new Map<string, MemorySlot>(); + for (const s of global) merged.set(s.label, s); + for (const s of project) merged.set(s.label, s); + const slots = Array.from(merged.values()).sort((a, b) => + a.label.localeCompare(b.label), + ); + return { success: true, slots }; + }); + + sdk.registerFunction( + "mem::slot-get", + async (data: { label?: string }) => { + const label = validateLabel(data?.label); + if (!label) return { success: false, error: "label required (lowercase, starts with letter, [a-z0-9_])" }; + const { slot, scope } = await readSlot(kv, label); + if (!slot) return { success: false, error: "slot not found" }; + return { success: true, slot, scope }; + }, + ); + + sdk.registerFunction( + "mem::slot-create", + async (data: { + label?: string; + content?: string; + sizeLimit?: number; + description?: string; + pinned?: boolean; + scope?: SlotScope; + }) => { + const label = validateLabel(data?.label); + if (!label) return { success: false, error: "label required (lowercase, starts with letter, [a-z0-9_])" }; + const scope = validateScope(data?.scope); + if (!scope) return { success: false, error: "scope must be 'project' or 'global'" }; + const sizeLimit = validateSizeLimit(data?.sizeLimit); + if (sizeLimit === null) { + return { success: false, error: "sizeLimit must be an integer between 1 and 20000" }; + } + const content = typeof data?.content === "string" ? data.content : ""; + if (content.length > sizeLimit) { + return { success: false, error: `content exceeds sizeLimit (${content.length} > ${sizeLimit})` }; + } + const description = typeof data?.description === "string" ? data.description : ""; + const pinned = typeof data?.pinned === "boolean" ? data.pinned : true; + return withKeyedLock(`slot:${label}`, async () => { + // Duplicate check is scope-local so a project slot can shadow a + // global slot with the same label — matches the read precedence. + const existing = await readSlotInScope(kv, label, scope); + if (existing) return { success: false, error: `slot already exists in ${scope} scope` }; + const ts = nowIso(); + const slot: MemorySlot = { + label, + content, + sizeLimit: sizeLimit as number, + description, + pinned, + readOnly: false, + scope, + createdAt: ts, + updatedAt: ts, + }; + await kv.set(scopeKv(scope), label, slot); + await recordAudit(kv, "slot_create", "mem::slot-create", [label], { + scope, + sizeLimit: slot.sizeLimit, + pinned: slot.pinned, + }); + return { success: true, slot }; + }); + }, + ); + + sdk.registerFunction( + "mem::slot-append", + async (data: { label?: string; text?: string }) => { + const label = validateLabel(data?.label); + if (!label) return { success: false, error: "label required" }; + const text = typeof data?.text === "string" ? data.text : ""; + if (!text) return { success: false, error: "text required" }; + return withKeyedLock(`slot:${label}`, async () => { + const { slot, scope } = await readSlot(kv, label); + if (!slot) return { success: false, error: "slot not found (use mem::slot-create first)" }; + if (slot.readOnly) return { success: false, error: "slot is read-only" }; + const sep = slot.content && !slot.content.endsWith("\n") ? "\n" : ""; + const next = `${slot.content}${sep}${text}`; + if (next.length > slot.sizeLimit) { + return { + success: false, + error: `append would exceed sizeLimit (${next.length} > ${slot.sizeLimit}). Use mem::slot-replace to compact first.`, + currentSize: slot.content.length, + sizeLimit: slot.sizeLimit, + }; + } + const updated: MemorySlot = { ...slot, content: next, updatedAt: nowIso() }; + await kv.set(scopeKv(scope), label, updated); + await recordAudit(kv, "slot_append", "mem::slot-append", [label], { + scope, + added: text.length, + total: next.length, + }); + return { success: true, slot: updated, size: next.length }; + }); + }, + ); + + sdk.registerFunction( + "mem::slot-replace", + async (data: { label?: string; content?: string }) => { + const label = validateLabel(data?.label); + if (!label) return { success: false, error: "label required" }; + if (typeof data?.content !== "string") return { success: false, error: "content required (string)" }; + return withKeyedLock(`slot:${label}`, async () => { + const { slot, scope } = await readSlot(kv, label); + if (!slot) return { success: false, error: "slot not found (use mem::slot-create first)" }; + if (slot.readOnly) return { success: false, error: "slot is read-only" }; + if (data.content.length > slot.sizeLimit) { + return { + success: false, + error: `content exceeds sizeLimit (${data.content.length} > ${slot.sizeLimit})`, + sizeLimit: slot.sizeLimit, + }; + } + const updated: MemorySlot = { ...slot, content: data.content, updatedAt: nowIso() }; + await kv.set(scopeKv(scope), label, updated); + await recordAudit(kv, "slot_replace", "mem::slot-replace", [label], { + scope, + before: slot.content.length, + after: data.content.length, + }); + return { success: true, slot: updated, size: data.content.length }; + }); + }, + ); + + sdk.registerFunction( + "mem::slot-delete", + async (data: { label?: string }) => { + const label = validateLabel(data?.label); + if (!label) return { success: false, error: "label required" }; + return withKeyedLock(`slot:${label}`, async () => { + const { slot, scope } = await readSlot(kv, label); + if (!slot) return { success: false, error: "slot not found" }; + if (slot.readOnly) return { success: false, error: "slot is read-only" }; + await kv.delete(scopeKv(scope), label); + await recordAudit(kv, "slot_delete", "mem::slot-delete", [label], { + scope, + size: slot.content.length, + }); + return { success: true }; + }); + }, + ); + + sdk.registerFunction( + "mem::slot-reflect", + async (data: { sessionId?: string; maxObservations?: number }) => { + if (!data?.sessionId || typeof data.sessionId !== "string") { + return { success: false, error: "sessionId required" }; + } + const max = + typeof data.maxObservations === "number" && + Number.isInteger(data.maxObservations) && + data.maxObservations > 0 + ? Math.min(200, data.maxObservations) + : 50; + const observations = await kv.list<CompressedObservation>( + KV.observations(data.sessionId), + ); + if (observations.length === 0) { + return { success: true, applied: 0, reason: "no observations for session" }; + } + const recent = observations + .slice() + .sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || "")) + .slice(0, max); + + const pendingLines: string[] = []; + const patternCounts = new Map<string, number>(); + const files = new Set<string>(); + for (const obs of recent) { + const title = (obs.title || "").toLowerCase(); + const narrative = (obs.narrative || "").toLowerCase(); + if (narrative.includes("todo") || title.includes("todo")) { + pendingLines.push(`- ${obs.title || obs.id}`); + } + if (obs.type === "error") { + patternCounts.set("errors", (patternCounts.get("errors") ?? 0) + 1); + } + if (obs.type === "command_run") { + patternCounts.set("commands", (patternCounts.get("commands") ?? 0) + 1); + } + if (obs.files) for (const f of obs.files) files.add(f); + } + + let applied = 0; + + if (pendingLines.length > 0) { + const pendingApplied = await withKeyedLock(`slot:pending_items`, async () => { + const { slot, scope } = await readSlot(kv, "pending_items"); + if (!slot) return false; + const already = new Set(slot.content.split("\n")); + const fresh = pendingLines.filter((line) => !already.has(line)); + if (fresh.length === 0) return false; + const sep = slot.content && !slot.content.endsWith("\n") ? "\n" : ""; + const next = `${slot.content}${sep}${fresh.join("\n")}`; + const truncated = next.length > slot.sizeLimit + ? next.slice(next.length - slot.sizeLimit) + : next; + await kv.set(scopeKv(scope), "pending_items", { + ...slot, + content: truncated, + updatedAt: nowIso(), + }); + return true; + }); + if (pendingApplied) applied++; + } + + if (patternCounts.size > 0) { + const patternsApplied = await withKeyedLock(`slot:session_patterns`, async () => { + const { slot, scope } = await readSlot(kv, "session_patterns"); + if (!slot) return false; + const summary = [ + `last reflection: ${nowIso()}`, + ...Array.from(patternCounts.entries()).map( + ([kind, count]) => `- ${kind}: ${count} in last ${recent.length} observations`, + ), + ].join("\n"); + const next = + summary.length > slot.sizeLimit ? summary.slice(0, slot.sizeLimit) : summary; + await kv.set(scopeKv(scope), "session_patterns", { + ...slot, + content: next, + updatedAt: nowIso(), + }); + return true; + }); + if (patternsApplied) applied++; + } + + if (files.size > 0) { + const ctxApplied = await withKeyedLock(`slot:project_context`, async () => { + const { slot, scope } = await readSlot(kv, "project_context"); + if (!slot) return false; + const already = slot.content; + const fresh = Array.from(files) + .filter((f) => !already.includes(f)) + .slice(0, 20); + if (fresh.length === 0) return false; + const header = + already.length === 0 ? "Files touched in recent sessions:" : ""; + const sep = already && !already.endsWith("\n") ? "\n" : ""; + const nextRaw = `${already}${sep}${header ? header + "\n" : ""}${fresh + .map((f) => `- ${f}`) + .join("\n")}`; + const next = + nextRaw.length > slot.sizeLimit + ? nextRaw.slice(nextRaw.length - slot.sizeLimit) + : nextRaw; + await kv.set(scopeKv(scope), "project_context", { + ...slot, + content: next, + updatedAt: nowIso(), + }); + return true; + }); + if (ctxApplied) applied++; + } + + if (applied > 0) { + await recordAudit(kv, "slot_reflect", "mem::slot-reflect", [data.sessionId], { + observationCount: recent.length, + slotsUpdated: applied, + }); + } + + return { success: true, applied, observationsReviewed: recent.length }; + }, + ); +} diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts new file mode 100644 index 0000000..5d0147d --- /dev/null +++ b/src/functions/smart-search.ts @@ -0,0 +1,386 @@ +import type { ISdk } from "iii-sdk"; +import type { + CompactLessonResult, + CompactSearchResult, + CompressedObservation, + HybridSearchResult, + Lesson, +} from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { recordAccessBatch } from "./access-tracker.js"; +import { + getAgentId, + isAgentScopeIsolated, + getFollowupWindowSeconds, +} from "../config.js"; +import { logger } from "../logger.js"; +import { getCounters } from "../telemetry/setup.js"; + +// #771: smart-search followup-rate diagnostic. Stored per session as +// the most recent search payload, used to detect whether the next +// search inside the window had a disjoint result set. sessionId is +// duplicated into the row so the hourly sweep can delete by it +// (StateKV.list returns values only). +export interface RecentSearch { + sessionId: string; + query: string; + resultIds: string[]; + at: number; +} + +// Module-scope counter mirror so `mem::diagnostic::followup-stats` can +// read the rate back without going through the OTEL collector. The +// OTEL counter is still the canonical export; this is an in-process +// convenience for `agentmemory status` + tests. +const followupStats = { + followupWithinWindow: 0, + agentInitiatedSearches: 0, +}; + +// Tracks the in-flight detection promises so tests (and shutdown +// flushes) can wait for all queued lock bodies to drain. The Set adds +// when a detection is queued and removes when it settles; size === 0 +// means no pending detections. +const pendingFollowups = new Set<Promise<void>>(); + +export function getFollowupStats(): { + followupWithinWindow: number; + agentInitiatedSearches: number; + rate: number; +} { + const total = followupStats.agentInitiatedSearches; + return { + ...followupStats, + rate: total > 0 ? followupStats.followupWithinWindow / total : 0, + }; +} + +export async function flushPendingFollowups(): Promise<void> { + // Snapshot the current pending set; new detections queued after the + // snapshot run in a fresh batch. + await Promise.all(Array.from(pendingFollowups)); +} + +export function resetFollowupStatsForTests(): void { + followupStats.followupWithinWindow = 0; + followupStats.agentInitiatedSearches = 0; +} + +// Compact mode trims each lesson's content for at-a-glance display. The +// full content is fetched via memory_lesson_recall when the caller needs it. +const LESSON_CONTENT_PREVIEW_CHARS = 240; + +export function registerSmartSearchFunction( + sdk: ISdk, + kv: StateKV, + searchFn: (query: string, limit: number) => Promise<HybridSearchResult[]>, +): void { + sdk.registerFunction("mem::smart-search", + async (data: { + query?: string; + expandIds?: Array<string | { obsId: string; sessionId: string }>; + limit?: number; + project?: string; + includeLessons?: boolean; + // optional per-call agent filter for runtimes routing many + // roles through one server. "*" opts out of the env-default + // scope and returns hits from every agent. + agentId?: string; + // #771: session anchor for the followup-rate diagnostic. The + // API trigger fills this from req.body / headers; direct + // sdk.trigger callers can pass it explicitly. + sessionId?: string; + // #771: marks viewer-originated searches so the diagnostic + // ignores them — only agent-initiated re-queries should count. + source?: string; + }) => { + + // Compute the agent filter once, up front. Both the expandIds + // branch and the hybrid-search branch consult it — otherwise + // expandIds becomes a cross-agent leak (#554 follow-up). + // + // #817 follow-up: fail-closed when isolated mode is on AND no + // agent id is resolvable from any source. Silently letting + // filterAgentId fall through to `undefined` would be the same + // cross-agent leak this filter is meant to prevent. + const isolated = isAgentScopeIsolated(); + const explicitAgentId = + typeof data.agentId === "string" && data.agentId.trim().length > 0 + ? data.agentId.trim() + : undefined; + const wildcardAgent = explicitAgentId === "*"; + const envAgentId = isolated ? getAgentId() : undefined; + const filterAgentId = wildcardAgent + ? undefined + : explicitAgentId ?? envAgentId; + if ( + isolated && + !wildcardAgent && + !explicitAgentId && + !envAgentId + ) { + throw new Error( + "mem::smart-search: AGENTMEMORY_AGENT_SCOPE=isolated is set but " + + "no agent id is available (env AGENT_ID unset and no explicit " + + "agentId in the call). Refusing to read cross-agent rows. " + + 'Pass agentId: "*" to opt in to a wildcard read.', + ); + } + + if (data.expandIds && data.expandIds.length > 0) { + const raw = data.expandIds.slice(0, 20); + const items = raw.map((entry) => { + if (typeof entry === "string") return { obsId: entry, sessionId: undefined as string | undefined }; + if (entry && typeof entry === "object" && typeof (entry as any).obsId === "string") { + return { obsId: (entry as any).obsId, sessionId: (entry as any).sessionId as string | undefined }; + } + return null; + }).filter((item): item is NonNullable<typeof item> => item !== null); + + const expanded: Array<{ + obsId: string; + sessionId: string; + observation: CompressedObservation; + }> = []; + + const results = await Promise.all( + items.map(({ obsId, sessionId }) => + findObservation(kv, obsId, sessionId).then((obs) => + obs ? { obsId, sessionId: obs.sessionId, observation: obs } : null, + ), + ), + ); + for (const r of results) { + if (r) expanded.push(r); + } + + const scoped = filterAgentId + ? expanded.filter((e) => e.observation.agentId === filterAgentId) + : expanded; + + void recordAccessBatch( + kv, + scoped.map((e) => e.observation.id), + ); + + const truncated = data.expandIds.length > raw.length; + logger.info("Smart search expanded", { + requested: data.expandIds.length, + attempted: raw.length, + returned: scoped.length, + filteredOutOfScope: expanded.length - scoped.length, + truncated, + }); + return { mode: "expanded", results: scoped, truncated }; + } + + if (!data.query || typeof data.query !== "string" || !data.query.trim()) { + return { mode: "compact", results: [], error: "query is required" }; + } + + const limit = Math.max(1, Math.min(data.limit ?? 20, 100)); + // Lesson recall stays capped: lessons are denser than raw + // observations so 10 covers most recall flows. + const lessonLimit = Math.min(limit, 10); + const includeLessons = data.includeLessons !== false; + + // Over-fetch when filtering. Hybrid search can't filter on + // agentId (BM25/vector indexes don't carry it), so we ask the + // searcher for more hits than we need and trim post-filter. 3× + // is a defensible middle ground: enough headroom for a small + // workload, capped at 300 so a 100-limit request never asks for + // thousands of hits. + const overFetchLimit = filterAgentId + ? Math.min(limit * 3, 300) + : limit; + + const [hybridResults, lessons] = await Promise.all([ + searchFn(data.query, overFetchLimit), + includeLessons + ? recallLessons(sdk, data.query, lessonLimit, data.project) + : Promise.resolve([]), + ]); + + const filteredHybrid = filterAgentId + ? hybridResults + .filter((r) => r.observation.agentId === filterAgentId) + .slice(0, limit) + : hybridResults.slice(0, limit); + + const compact: CompactSearchResult[] = filteredHybrid.map((r) => ({ + obsId: r.observation.id, + sessionId: r.sessionId, + title: r.observation.title, + type: r.observation.type, + score: r.combinedScore, + timestamp: r.observation.timestamp, + })); + + void recordAccessBatch( + kv, + compact.map((r) => r.obsId), + ); + + // #771: followup-rate diagnostic. Only fires for agent-initiated + // searches that carry a sessionId — viewer-originated searches + // (source === "viewer") and direct-sdk callers without a session + // anchor are skipped. The result-set comparison uses obsIds: a + // disjoint set under the window suggests the previous call's + // results were not used, which is our directional proxy for + // reader-failure-with-evidence. + if ( + data.sessionId && + typeof data.sessionId === "string" && + data.source !== "viewer" && + compact.length > 0 + ) { + // Skip detection when retrieval returned nothing: an empty + // result set is a retrieval failure, not a reader-failure + // signal. Counting it as "disjoint from prior" would inflate + // the rate every time search returns no hits. + followupStats.agentInitiatedSearches++; + // Off the critical response path. The withKeyedLock(sessionId) + // call serializes detection per session, so two rapid + // back-to-back searches from the same agent still see ordered + // prior-row writes — the second call's lock body queues + // behind the first's. Other sessions run in parallel. + const sessionIdForFollowup = data.sessionId; + const queryForFollowup = data.query; + const compactForFollowup = compact; + const detection = withKeyedLock( + `recent-searches:${sessionIdForFollowup}`, + () => + detectFollowup( + kv, + sessionIdForFollowup, + queryForFollowup, + compactForFollowup, + ), + ) + .catch((err) => { + logger.warn("Smart search followup detection failed", { + sessionId: sessionIdForFollowup, + error: err instanceof Error ? err.message : String(err), + }); + }) + .finally(() => { + pendingFollowups.delete(detection); + }); + pendingFollowups.add(detection); + } + + logger.info("Smart search compact", { + query: data.query, + results: compact.length, + lessons: lessons.length, + }); + const response: { + mode: "compact"; + results: CompactSearchResult[]; + lessons?: CompactLessonResult[]; + } = { mode: "compact", results: compact }; + if (includeLessons) response.lessons = lessons; + return response; + }, + ); +} + +async function recallLessons( + sdk: ISdk, + query: string, + limit: number, + project?: string, +): Promise<CompactLessonResult[]> { + try { + const result = (await sdk.trigger({ + function_id: "mem::lesson-recall", + payload: { query, limit, project }, + })) as { success?: boolean; lessons?: Array<Lesson & { score?: number }> }; + if (!result?.success || !Array.isArray(result.lessons)) return []; + return result.lessons.map((l) => ({ + lessonId: l.id, + content: + l.content.length > LESSON_CONTENT_PREVIEW_CHARS + ? l.content.slice(0, LESSON_CONTENT_PREVIEW_CHARS) + "…" + : l.content, + confidence: l.confidence, + score: l.score ?? l.confidence, + createdAt: l.createdAt, + project: l.project, + tags: l.tags ?? [], + })); + } catch (err) { + logger.warn("Smart search: mem::lesson-recall failed; returning empty lesson list", { + error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} + +async function detectFollowup( + kv: StateKV, + sessionId: string, + query: string, + compact: CompactSearchResult[], +): Promise<void> { + const now = Date.now(); + const windowMs = Math.max(1, getFollowupWindowSeconds()) * 1000; + const currentIds = compact.map((r) => r.obsId); + const current: RecentSearch = { sessionId, query, resultIds: currentIds, at: now }; + + const prior = await kv + .get<RecentSearch>(KV.recentSearches, sessionId) + .catch(() => null); + + await kv.set(KV.recentSearches, sessionId, current); + + if (!prior || typeof prior.at !== "number") return; + if (now - prior.at > windowMs) return; + // Same query inside the window is a retry, not a follow-up; skip so a + // duplicate request from a flaky client doesn't inflate the metric. + if (typeof prior.query === "string" && prior.query === query) return; + + const priorIds = Array.isArray(prior.resultIds) ? prior.resultIds : []; + const priorSet = new Set(priorIds); + const hasOverlap = currentIds.some((id) => priorSet.has(id)); + if (hasOverlap) return; + + getCounters().smartSearchFollowupWithinWindow.add(1); + followupStats.followupWithinWindow++; + logger.info("Smart search followup detected", { + sessionId, + windowSeconds: Math.round(windowMs / 1000), + priorQuery: prior.query, + nextQuery: query, + priorResultCount: priorIds.length, + nextResultCount: currentIds.length, + }); +} + +async function findObservation( + kv: StateKV, + obsId: string, + sessionIdHint?: string, +): Promise<CompressedObservation | null> { + if (sessionIdHint) { + const obs = await kv + .get<CompressedObservation>(KV.observations(sessionIdHint), obsId) + .catch(() => null); + if (obs) return obs; + } + + const sessions = await kv.list<{ id: string }>(KV.sessions); + for (let i = 0; i < sessions.length; i += 5) { + const batch = sessions.slice(i, i + 5); + const results = await Promise.all( + batch.map((s) => + kv.get<CompressedObservation>(KV.observations(s.id), obsId).catch(() => null), + ), + ); + const found = results.find((r) => r !== null); + if (found) return found; + } + return null; +} diff --git a/src/functions/snapshot.ts b/src/functions/snapshot.ts new file mode 100644 index 0000000..40430ce --- /dev/null +++ b/src/functions/snapshot.ts @@ -0,0 +1,232 @@ +import type { ISdk } from "iii-sdk"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { + SnapshotMeta, + Session, + Memory, + GraphNode, + AccessLogExport, +} from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { VERSION } from "../version.js"; +import { logger } from "../logger.js"; + +const COMMIT_HASH_RE = /^[0-9a-f]{7,40}$/i; + +const execFileAsync = promisify(execFile); + +async function gitExec(dir: string, args: string[]): Promise<string> { + const { stdout } = await execFileAsync("git", args, { cwd: dir }); + return stdout.trim(); +} + +async function ensureGitRepo(dir: string): Promise<void> { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + if (!existsSync(join(dir, ".git"))) { + await gitExec(dir, ["init"]); + await gitExec(dir, ["config", "user.email", "agentmemory@local"]); + await gitExec(dir, ["config", "user.name", "agentmemory"]); + } +} + +export function registerSnapshotFunction( + sdk: ISdk, + kv: StateKV, + snapshotDir: string, +): void { + sdk.registerFunction("mem::snapshot-create", + async (data?: { message?: string }) => { + + try { + await ensureGitRepo(snapshotDir); + const ts = new Date().toISOString(); + + const sessions = await kv.list<Session>(KV.sessions); + const memories = await kv.list<Memory>(KV.memories); + const graphNodes = await kv.list<GraphNode>(KV.graphNodes); + const accessLogs = await kv + .list<AccessLogExport>(KV.accessLog) + .catch(() => [] as AccessLogExport[]); + + const observations: Record<string, unknown[]> = {}; + for (const session of sessions) { + const obs = await kv + .list(KV.observations(session.id)) + .catch(() => []); + if (obs.length > 0) { + observations[session.id] = obs; + } + } + + const state = { + version: VERSION, + timestamp: ts, + sessions, + memories, + graphNodes, + observations, + accessLogs, + }; + + writeFileSync( + join(snapshotDir, "state.json"), + JSON.stringify(state, null, 2), + "utf-8", + ); + + await gitExec(snapshotDir, ["add", "."]); + + const message = data?.message || `Snapshot ${ts}`; + try { + await gitExec(snapshotDir, ["commit", "-m", message]); + } catch (commitErr) { + const errMsg = + commitErr instanceof Error ? commitErr.message : String(commitErr); + if (errMsg.includes("nothing to commit")) { + return { success: true, message: "No changes to snapshot" }; + } + throw commitErr; + } + + const commitHash = await gitExec(snapshotDir, ["rev-parse", "HEAD"]); + + const meta: SnapshotMeta = { + id: generateId("snap"), + commitHash, + createdAt: ts, + message, + stats: { + sessions: sessions.length, + observations: Object.values(observations).reduce( + (sum, arr) => sum + arr.length, + 0, + ), + memories: memories.length, + graphNodes: graphNodes.length, + }, + }; + + await recordAudit(kv, "export", "mem::snapshot-create", [meta.id], { + commitHash, + stats: meta.stats, + }); + + logger.info("Snapshot created", { commitHash }); + return { success: true, snapshot: meta }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Snapshot failed", { error: msg }); + return { success: false, error: msg }; + } + }, + ); + + sdk.registerFunction("mem::snapshot-list", async () => { + try { + if (!existsSync(join(snapshotDir, ".git"))) { + return { snapshots: [] }; + } + const log = await gitExec(snapshotDir, [ + "log", + "--format=%H|%aI|%s", + "-20", + ]); + const snapshots = log + .split("\n") + .filter(Boolean) + .map((line) => { + const parts = line.split("|"); + const [hash, date] = parts; + const msg = parts.slice(2).join("|"); + return { commitHash: hash, createdAt: date, message: msg }; + }); + return { snapshots }; + } catch { + return { snapshots: [] }; + } + }); + + sdk.registerFunction("mem::snapshot-restore", + async (data: { commitHash: string } | undefined) => { + if (!data || typeof data.commitHash !== "string" || !data.commitHash.trim()) { + return { success: false, error: "commitHash is required" }; + } + if (!COMMIT_HASH_RE.test(data.commitHash)) { + return { success: false, error: "Invalid commitHash format" }; + } + + try { + await gitExec(snapshotDir, [ + "checkout", + data.commitHash, + "--", + "state.json", + ]); + const content = readFileSync(join(snapshotDir, "state.json"), "utf-8"); + const state = JSON.parse(content) as { + sessions?: Array<{ id: string } & Record<string, unknown>>; + memories?: Array<{ id: string } & Record<string, unknown>>; + graphNodes?: Array<{ id: string } & Record<string, unknown>>; + observations?: Record< + string, + Array<{ id: string } & Record<string, unknown>> + >; + accessLogs?: AccessLogExport[]; + }; + + if (state.sessions) { + for (const session of state.sessions) { + await kv.set(KV.sessions, session.id, session); + } + } + if (state.memories) { + for (const memory of state.memories) { + await kv.set(KV.memories, memory.id, memory); + } + } + if (state.graphNodes) { + for (const node of state.graphNodes) { + await kv.set(KV.graphNodes, node.id, node); + } + } + if (state.observations) { + for (const [sessionId, obs] of Object.entries(state.observations)) { + for (const o of obs) { + await kv.set(KV.observations(sessionId), o.id, o); + } + } + } + if (state.accessLogs) { + for (const log of state.accessLogs) { + await kv.set(KV.accessLog, log.memoryId, log); + } + } + + await gitExec(snapshotDir, ["checkout", "HEAD", "--", "state.json"]); + + await recordAudit(kv, "import", "mem::snapshot-restore", [], { + commitHash: data.commitHash, + sessions: state.sessions?.length || 0, + memories: state.memories?.length || 0, + graphNodes: state.graphNodes?.length || 0, + }); + + logger.info("Snapshot restored", { + commitHash: data.commitHash, + }); + return { success: true, commitHash: data.commitHash }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Snapshot restore failed", { error: msg }); + return { success: false, error: msg }; + } + }, + ); +} diff --git a/src/functions/summarize.ts b/src/functions/summarize.ts new file mode 100644 index 0000000..4c501ca --- /dev/null +++ b/src/functions/summarize.ts @@ -0,0 +1,398 @@ +import type { ISdk } from "iii-sdk"; +import type { + CompressedObservation, + SessionSummary, + MemoryProvider, + Session, +} from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { + SUMMARY_SYSTEM, + buildSummaryPrompt, + REDUCE_SYSTEM, + buildReducePrompt, +} from "../prompts/summary.js"; +import { getXmlTag, getXmlChildren } from "../prompts/xml.js"; +import { SummaryOutputSchema } from "../eval/schemas.js"; +import { validateOutput } from "../eval/validator.js"; +import { scoreSummary } from "../eval/quality.js"; +import type { MetricsStore } from "../eval/metrics-store.js"; +import { safeAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +// Per-chunk observation budget when a session is too large to fit in one +// LLM call. Default ≈ 50k input tokens per chunk at ~110 tok/obs — fits +// comfortably in 128k-window models. Override via SUMMARIZE_CHUNK_SIZE. +const CHUNK_SIZE_DEFAULT = 400; +// Concurrent in-flight chunk calls. 6 keeps a 100-chunk session under +// iii's 180s function-invocation timeout at ~8s/call while staying +// inside generous-but-not-unlimited provider rate limits (well below +// OpenAI free tier's 500 RPM). High-throughput providers +// (Novita / DeepInfra / DeepSeek) typically allow 100+ concurrent — set +// SUMMARIZE_CHUNK_CONCURRENCY higher to cover ~1000+ chunk sessions. +const CHUNK_CONCURRENCY_DEFAULT = 6; +// Bail on the merged summary if more than this fraction of chunks fail +// to parse — a half-blind narrative is worse than a clean error. +const MAX_SKIP_RATIO = 0.5; + +function getChunkSize(): number { + const raw = process.env.SUMMARIZE_CHUNK_SIZE; + if (!raw) return CHUNK_SIZE_DEFAULT; + const n = parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : CHUNK_SIZE_DEFAULT; +} + +function getChunkConcurrency(): number { + const raw = process.env.SUMMARIZE_CHUNK_CONCURRENCY; + if (!raw) return CHUNK_CONCURRENCY_DEFAULT; + const n = parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : CHUNK_CONCURRENCY_DEFAULT; +} + +// One chunk call with retry-once. Returns null when both attempts fail — +// whether by parse failure, provider 4xx (content rejected by upstream +// filters), or transient network/5xx errors that didn't recover on retry. +// All failure modes are equivalent at this layer: the chunk is unusable, +// skip it and let the caller decide via the skip-ratio bailout whether +// the overall summary is still trustworthy. Errors that affect every +// chunk (auth, model down) will trip the bailout naturally. +async function summarizeChunkWithRetry( + provider: MemoryProvider, + chunk: CompressedObservation[], + sessionId: string, + project: string, + idx: number, + total: number, +): Promise<SessionSummary | null> { + for (let attempt = 1; attempt <= 2; attempt++) { + try { + const xml = await provider.summarize( + SUMMARY_SYSTEM, + buildSummaryPrompt(chunk), + ); + const parsed = parseSummaryXml(xml, sessionId, project, chunk.length); + if (parsed) return parsed; + logger.warn("Summarize chunk parse failed", { + sessionId, + chunk: `${idx + 1}/${total}`, + attempt, + }); + } catch (err) { + logger.warn("Summarize chunk LLM call failed", { + sessionId, + chunk: `${idx + 1}/${total}`, + attempt, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return null; +} + +// Returns the final summary XML string. For sessions ≤ chunk size, this is +// a single LLM call (legacy behavior). For larger sessions, observations +// are split into chunks processed in parallel batches, each chunk retried +// once on parse failure, persistently-bad chunks skipped, and remaining +// partials merged via a reduce call. +async function produceSummaryXml( + provider: MemoryProvider, + compressed: CompressedObservation[], + sessionId: string, + project: string, +): Promise<{ + response: string; + mode: "single" | "chunked"; + chunks: number; + skipped?: number; +}> { + const chunkSize = getChunkSize(); + if (compressed.length <= chunkSize) { + const response = await provider.summarize( + SUMMARY_SYSTEM, + buildSummaryPrompt(compressed), + ); + return { response, mode: "single", chunks: 1 }; + } + + const chunks: CompressedObservation[][] = []; + for (let i = 0; i < compressed.length; i += chunkSize) { + chunks.push(compressed.slice(i, i + chunkSize)); + } + const concurrency = getChunkConcurrency(); + logger.info("Summarize chunking session", { + sessionId, + chunks: chunks.length, + chunkSize, + concurrency, + totalObservations: compressed.length, + }); + + // Sparse array preserves chunk → index mapping after parallel resolution, + // so the reduce step sees partials in chronological order even when some + // were skipped. + const partialByIdx: Array<SessionSummary | null> = new Array(chunks.length).fill(null); + for (let batchStart = 0; batchStart < chunks.length; batchStart += concurrency) { + const batch = chunks.slice(batchStart, batchStart + concurrency); + await Promise.all( + batch.map(async (chunk, j) => { + const idx = batchStart + j; + partialByIdx[idx] = await summarizeChunkWithRetry( + provider, + chunk, + sessionId, + project, + idx, + chunks.length, + ); + }), + ); + } + + const skipped = partialByIdx.filter((p) => p === null).length; + const partials = partialByIdx.filter((p): p is SessionSummary => p !== null); + + if (skipped > Math.floor(chunks.length * MAX_SKIP_RATIO)) { + throw new Error( + `too_many_chunks_skipped: ${skipped}/${chunks.length} chunks failed to parse after retry`, + ); + } + if (skipped > 0) { + logger.warn("Summarize chunks partially skipped", { + sessionId, + skipped, + total: chunks.length, + }); + } + + const reduceInput = partials.map((p) => { + const originalIdx = partialByIdx.indexOf(p); + return { + title: p.title, + narrative: p.narrative, + keyDecisions: p.keyDecisions, + filesModified: p.filesModified, + concepts: p.concepts, + obsRangeStart: originalIdx * chunkSize + 1, + obsRangeEnd: Math.min((originalIdx + 1) * chunkSize, compressed.length), + }; + }); + const response = await provider.summarize( + REDUCE_SYSTEM, + buildReducePrompt(reduceInput), + ); + return { response, mode: "chunked", chunks: chunks.length, skipped }; +} + +// #783: many LLMs (DeepSeek, GPT variants, some Anthropic responses) +// wrap structured XML in markdown code fences or add conversational +// text before/after. Strip those wrappers before the tag regex so a +// well-formed summary doesn't get silently dropped as parse_failed. +function stripXmlWrappers(raw: string): string { + if (!raw) return ""; + let cleaned = raw.trim(); + // ```xml ... ``` or ``` ... ``` fences (anywhere in the payload). + cleaned = cleaned.replace(/```\s*xml\s*\n?/gi, ""); + cleaned = cleaned.replace(/```/g, ""); + cleaned = cleaned.trim(); + // If preamble / postamble surrounds the XML root, peel it off. + const rootMatch = cleaned.match( + /(<[a-zA-Z_][a-zA-Z0-9_-]*>[\s\S]*<\/[a-zA-Z_][a-zA-Z0-9_-]*>)/, + ); + if (rootMatch && rootMatch[1]) return rootMatch[1].trim(); + return cleaned; +} + +function parseSummaryXml( + xml: string, + sessionId: string, + project: string, + obsCount: number, +): SessionSummary | null { + const cleaned = stripXmlWrappers(xml); + const title = getXmlTag(cleaned, "title"); + if (!title) return null; + + return { + sessionId, + project, + createdAt: new Date().toISOString(), + title, + narrative: getXmlTag(cleaned, "narrative"), + keyDecisions: getXmlChildren(cleaned, "decisions", "decision"), + filesModified: getXmlChildren(cleaned, "files", "file"), + concepts: getXmlChildren(cleaned, "concepts", "concept"), + observationCount: obsCount, + }; +} + +export function registerSummarizeFunction( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, + metricsStore?: MetricsStore, +): void { + sdk.registerFunction("mem::summarize", + async (data: { sessionId: string } | undefined) => { + const startMs = Date.now(); + if (!data || typeof data.sessionId !== "string" || !data.sessionId.trim()) { + return { success: false, error: "sessionId is required" }; + } + const sessionId = data.sessionId.trim(); + + const session = await kv.get<Session>(KV.sessions, sessionId); + if (!session) { + logger.warn("Session not found for summarize", { + sessionId, + }); + return { success: false, error: "session_not_found" }; + } + + const observations = await kv.list<CompressedObservation>( + KV.observations(sessionId), + ); + const compressed = observations.filter((o) => o.title); + + if (compressed.length === 0) { + logger.info("No observations to summarize", { + sessionId, + }); + return { success: false, error: "no_observations" }; + } + + if (provider.name === "noop") { + logger.info("Summarize skipped — no LLM provider configured", { + sessionId, + }); + return { + success: false, + error: "no_provider", + reason: + "No LLM provider key set; Summarize is a no-op. Set ANTHROPIC_API_KEY (or GEMINI/OPENROUTER/MINIMAX) in ~/.agentmemory/.env to enable.", + }; + } + + try { + // #783: chunk-level produceSummaryXml retries internally, but + // the final merge used to parse once and bail. Wrap the + // produce-and-parse pair in the same 2-attempt loop so a + // markdown-wrapped or otherwise wrapped response gets a + // second roll-of-the-dice instead of dropping the summary. + let summary: SessionSummary | null = null; + let response = ""; + let mode = "single"; + let chunks = 1; + for (let attempt = 1; attempt <= 2; attempt++) { + const produced = await produceSummaryXml( + provider, + compressed, + sessionId, + session.project, + ); + response = produced.response; + mode = produced.mode; + chunks = produced.chunks; + if (!response || !response.trim()) { + logger.warn("Empty provider response on summarize", { + sessionId, + provider: provider.name, + mode, + chunks, + observationCount: compressed.length, + attempt, + }); + continue; + } + summary = parseSummaryXml( + response, + sessionId, + session.project, + compressed.length, + ); + if (summary) break; + logger.warn("Failed to parse summary XML", { sessionId, attempt }); + } + + if (!response || !response.trim()) { + const latencyMs = Date.now() - startMs; + if (metricsStore) { + await metricsStore.record("mem::summarize", latencyMs, false); + } + return { success: false, error: "empty_provider_response" }; + } + + if (!summary) { + const latencyMs = Date.now() - startMs; + if (metricsStore) { + await metricsStore.record("mem::summarize", latencyMs, false); + } + return { success: false, error: "parse_failed" }; + } + + const summaryForValidation = { + title: summary.title, + narrative: summary.narrative, + keyDecisions: summary.keyDecisions, + filesModified: summary.filesModified, + concepts: summary.concepts, + }; + const validation = validateOutput( + SummaryOutputSchema, + summaryForValidation, + "mem::summarize", + ); + + if (!validation.valid) { + const latencyMs = Date.now() - startMs; + if (metricsStore) { + await metricsStore.record("mem::summarize", latencyMs, false); + } + logger.warn("Summary validation failed", { + sessionId, + errors: validation.result.errors, + }); + return { success: false, error: "validation_failed" }; + } + + const qualityScore = scoreSummary(summaryForValidation); + + await kv.set(KV.summaries, sessionId, summary); + await safeAudit(kv, "compress", "mem::summarize", [sessionId], { + title: summary.title, + observationCount: compressed.length, + }); + + const latencyMs = Date.now() - startMs; + if (metricsStore) { + await metricsStore.record( + "mem::summarize", + latencyMs, + true, + qualityScore, + ); + } + + logger.info("Session summarized", { + sessionId, + title: summary.title, + decisions: summary.keyDecisions.length, + qualityScore, + valid: validation.valid, + }); + + return { success: true, summary, qualityScore }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const latencyMs = Date.now() - startMs; + if (metricsStore) { + await metricsStore.record("mem::summarize", latencyMs, false); + } + logger.error("Summarize failed", { + sessionId, + error: msg, + }); + return { success: false, error: msg }; + } + }, + ); +} diff --git a/src/functions/team.ts b/src/functions/team.ts new file mode 100644 index 0000000..a6461fe --- /dev/null +++ b/src/functions/team.ts @@ -0,0 +1,160 @@ +import type { ISdk } from "iii-sdk"; +import type { + TeamConfig, + TeamSharedItem, + TeamProfile, + Memory, +} from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +const VALID_ITEM_TYPES = new Set(["memory", "pattern", "observation"]); + +export function registerTeamFunction( + sdk: ISdk, + kv: StateKV, + config: TeamConfig, +): void { + sdk.registerFunction("mem::team-share", + async (data: { + itemId: string; + itemType: "memory" | "pattern" | "observation"; + sessionId?: string; + project?: string; + }) => { + if (!data) { + return { success: false, error: "payload required" }; + } + if (!data.itemId || !data.itemType) { + return { success: false, error: "itemId and itemType are required" }; + } + if (!VALID_ITEM_TYPES.has(data.itemType)) { + return { success: false, error: `Invalid itemType: ${data.itemType}` }; + } + + let content: unknown; + if (data.itemType === "observation") { + if (!data.sessionId) { + return { + success: false, + error: "sessionId is required for observations", + }; + } + content = await kv.get(KV.observations(data.sessionId), data.itemId); + } else { + content = await kv.get<Memory>(KV.memories, data.itemId); + } + if (!content) { + return { success: false, error: "Item not found" }; + } + + const shared: TeamSharedItem = { + id: generateId("ts"), + sharedBy: config.userId, + sharedAt: new Date().toISOString(), + type: data.itemType, + content, + project: data.project || "", + visibility: "shared", + }; + + await kv.set(KV.teamShared(config.teamId), shared.id, shared); + + await recordAudit(kv, "share", "mem::team-share", [data.itemId], { + teamId: config.teamId, + userId: config.userId, + itemType: data.itemType, + }); + + logger.info("Team share", { + teamId: config.teamId, + itemId: data.itemId, + }); + return { success: true, sharedItem: shared }; + }, + ); + + sdk.registerFunction("mem::team-feed", + async (data?: { limit?: number }) => { + const limit = data?.limit ?? 20; + const items = await kv.list<TeamSharedItem>(KV.teamShared(config.teamId)); + + const filtered = items.filter((i) => i.visibility === "shared"); + const sorted = filtered + .sort( + (a, b) => + new Date(b.sharedAt).getTime() - new Date(a.sharedAt).getTime(), + ) + .slice(0, limit); + + return { items: sorted, total: filtered.length }; + }, + ); + + sdk.registerFunction("mem::team-profile", async () => { + const items = await kv.list<TeamSharedItem>(KV.teamShared(config.teamId)); + + const members = [...new Set(items.map((i) => i.sharedBy))]; + + const conceptCounts = new Map<string, number>(); + const fileCounts = new Map<string, number>(); + const patterns: string[] = []; + + for (const item of items) { + if (item.type === "memory" || item.type === "pattern") { + const mem = item.content as Memory; + if (mem?.concepts) { + for (const c of mem.concepts) { + conceptCounts.set(c, (conceptCounts.get(c) || 0) + 1); + } + } + if (mem?.files) { + for (const f of mem.files) { + fileCounts.set(f, (fileCounts.get(f) || 0) + 1); + } + } + if (item.type === "pattern" && mem?.content) { + patterns.push(mem.content.slice(0, 100)); + } + } + } + + const topConcepts = [...conceptCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([concept, frequency]) => ({ concept, frequency })); + + const topFiles = [...fileCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([file, frequency]) => ({ file, frequency })); + + const profile: TeamProfile = { + teamId: config.teamId, + members, + topConcepts, + topFiles, + sharedPatterns: patterns.slice(0, 10), + totalSharedItems: items.length, + updatedAt: new Date().toISOString(), + }; + + await kv.set(KV.teamProfile(config.teamId), "profile", profile); + await recordAudit( + kv, + "share", + "mem::team-profile", + ["profile"], + { + teamId: config.teamId, + members: members.length, + totalSharedItems: items.length, + }, + undefined, + config.userId, + ); + return profile; + }); +} diff --git a/src/functions/temporal-graph.ts b/src/functions/temporal-graph.ts new file mode 100644 index 0000000..1e8d1df --- /dev/null +++ b/src/functions/temporal-graph.ts @@ -0,0 +1,460 @@ +import type { ISdk } from "iii-sdk"; +import type { + GraphNode, + GraphEdge, + GraphEdgeType, + EdgeContext, + TemporalState, + MemoryProvider, +} from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import type { StateKV } from "../state/kv.js"; +import { logger } from "../logger.js"; + +const TEMPORAL_EXTRACTION_SYSTEM = `You are a temporal knowledge extraction engine. Given observations, extract entities AND their temporal relationships with full context metadata. + +For each relationship, you MUST provide: +1. Semantic relation type +2. Temporal validity (when this fact became true in the real world) +3. Context metadata: WHY this relationship exists, what reasoning led to it, what alternatives were considered + +Output EXACTLY this XML: +<temporal_graph> + <entities> + <entity type="file|function|concept|error|decision|pattern|library|person|project|preference|location|organization|event" name="exact name"> + <property key="key">value</property> + <alias>alternate name</alias> + </entity> + </entities> + <relationships> + <relationship type="uses|imports|modifies|causes|fixes|depends_on|related_to|works_at|prefers|blocked_by|caused_by|optimizes_for|rejected|avoids|located_in|succeeded_by" + source="entity name" target="entity name" weight="0.1-1.0" + valid_from="ISO date or 'unknown'" valid_to="ISO date or 'current'"> + <reasoning>WHY this relationship exists</reasoning> + <sentiment>positive|negative|neutral</sentiment> + <alternatives> + <alt>alternative that was considered</alt> + </alternatives> + </relationship> + </relationships> +</temporal_graph> + +Rules: +- NEVER overwrite existing relationships — always create new versioned edges +- Extract temporal validity from context clues ("since last month", "in 2024", "currently") +- Capture reasoning/motivation behind each relationship +- Weight relationships by directness: 1.0 = explicit statement, 0.5 = inferred, 0.1 = speculative`; + +function parseTemporalGraphXml( + xml: string, + observationIds: string[], +): { nodes: GraphNode[]; edges: GraphEdge[] } { + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const now = new Date().toISOString(); + + const entityRegex = + /<entity\s+type="([^"]+)"\s+name="([^"]+)"[^>]*>([\s\S]*?)<\/entity>/g; + let match; + while ((match = entityRegex.exec(xml)) !== null) { + const type = match[1] as GraphNode["type"]; + const name = match[2]; + const propsBlock = match[3]; + const properties: Record<string, string> = {}; + const aliases: string[] = []; + + const propRegex = /<property\s+key="([^"]+)">([^<]*)<\/property>/g; + let propMatch; + while ((propMatch = propRegex.exec(propsBlock)) !== null) { + properties[propMatch[1]] = propMatch[2]; + } + + const aliasRegex = /<alias>([^<]+)<\/alias>/g; + while ((propMatch = aliasRegex.exec(propsBlock)) !== null) { + aliases.push(propMatch[1]); + } + + nodes.push({ + id: generateId("gn"), + type, + name, + properties, + sourceObservationIds: observationIds, + createdAt: now, + aliases: aliases.length > 0 ? aliases : undefined, + }); + } + + const relRegex = + /<relationship\s+type="([^"]+)"\s+source="([^"]+)"\s+target="([^"]+)"\s+weight="([^"]+)"(?:\s+valid_from="([^"]*)")?(?:\s+valid_to="([^"]*)")?[^>]*>([\s\S]*?)<\/relationship>/g; + while ((match = relRegex.exec(xml)) !== null) { + const type = match[1] as GraphEdgeType; + const sourceName = match[2]; + const targetName = match[3]; + const parsedWeight = parseFloat(match[4]); + const weight = Number.isNaN(parsedWeight) ? 0.5 : parsedWeight; + const validFrom = match[5] || undefined; + const validTo = match[6] || undefined; + const metaBlock = match[7] || ""; + + const sourceNode = nodes.find( + (n) => + n.name === sourceName || + (n.aliases && n.aliases.includes(sourceName)), + ); + const targetNode = nodes.find( + (n) => + n.name === targetName || + (n.aliases && n.aliases.includes(targetName)), + ); + + if (sourceNode && targetNode) { + const reasoning = + metaBlock.match(/<reasoning>([^<]*)<\/reasoning>/)?.[1] || undefined; + const sentiment = + metaBlock.match(/<sentiment>([^<]*)<\/sentiment>/)?.[1] || undefined; + const alternatives: string[] = []; + const altRegex = /<alt>([^<]+)<\/alt>/g; + let altMatch; + while ((altMatch = altRegex.exec(metaBlock)) !== null) { + alternatives.push(altMatch[1]); + } + + const context: EdgeContext = {}; + if (reasoning) context.reasoning = reasoning; + if (sentiment) context.sentiment = sentiment; + if (alternatives.length > 0) context.alternatives = alternatives; + context.confidence = Math.max(0, Math.min(1, weight)); + + edges.push({ + id: generateId("ge"), + type, + sourceNodeId: sourceNode.id, + targetNodeId: targetNode.id, + weight: Math.max(0, Math.min(1, weight)), + sourceObservationIds: observationIds, + createdAt: now, + tcommit: now, + tvalid: + validFrom && validFrom !== "unknown" ? validFrom : undefined, + tvalidEnd: + validTo && validTo !== "current" ? validTo : undefined, + context: Object.keys(context).length > 0 ? context : undefined, + version: 1, + isLatest: true, + }); + } + } + + return { nodes, edges }; +} + +export function registerTemporalGraphFunctions( + sdk: ISdk, + kv: StateKV, + provider: MemoryProvider, +): void { + sdk.registerFunction("mem::temporal-graph-extract", + async (data: { + observations: Array<{ + id: string; + title: string; + narrative: string; + concepts: string[]; + files: string[]; + type: string; + timestamp: string; + }>; + }) => { + if (!data.observations || data.observations.length === 0) { + return { success: false, error: "No observations provided" }; + } + + const items = data.observations + .map( + (o, i) => + `[${i + 1}] Type: ${o.type}\nTimestamp: ${o.timestamp}\nTitle: ${o.title}\nNarrative: ${o.narrative}\nConcepts: ${(o.concepts ?? []).join(", ")}\nFiles: ${(o.files ?? []).join(", ")}`, + ) + .join("\n\n"); + + try { + const response = await provider.compress( + TEMPORAL_EXTRACTION_SYSTEM, + `Extract temporal knowledge graph from:\n\n${items}`, + ); + + const obsIds = data.observations.map((o) => o.id); + const { nodes, edges } = parseTemporalGraphXml(response, obsIds); + + const existingNodes = await kv.list<GraphNode>(KV.graphNodes); + const existingEdges = await kv.list<GraphEdge>(KV.graphEdges); + + const idRemap = new Map<string, string>(); + for (const node of nodes) { + const existing = existingNodes.find( + (n) => + n.name === node.name && n.type === node.type, + ); + if (existing) { + const oldId = node.id; + const merged = { + ...existing, + sourceObservationIds: [ + ...new Set([ + ...existing.sourceObservationIds, + ...obsIds, + ]), + ], + properties: { ...existing.properties, ...node.properties }, + updatedAt: new Date().toISOString(), + aliases: [ + ...new Set([ + ...(existing.aliases || []), + ...(node.aliases || []), + ]), + ], + }; + if (merged.aliases.length === 0) delete (merged as any).aliases; + await kv.set(KV.graphNodes, existing.id, merged); + node.id = existing.id; + idRemap.set(oldId, existing.id); + } else { + await kv.set(KV.graphNodes, node.id, node); + existingNodes.push(node); + } + } + + for (const edge of edges) { + if (idRemap.has(edge.sourceNodeId)) { + edge.sourceNodeId = idRemap.get(edge.sourceNodeId)!; + } + if (idRemap.has(edge.targetNodeId)) { + edge.targetNodeId = idRemap.get(edge.targetNodeId)!; + } + const existingKey = `${edge.sourceNodeId}|${edge.targetNodeId}|${edge.type}`; + const existingEdge = existingEdges.find( + (e) => + `${e.sourceNodeId}|${e.targetNodeId}|${e.type}` === + existingKey, + ); + + if (existingEdge) { + const updatedOld = { + ...existingEdge, + isLatest: false, + tvalidEnd: + existingEdge.tvalidEnd || new Date().toISOString(), + supersededBy: edge.id, + }; + await kv.set(KV.graphEdges, existingEdge.id, updatedOld); + + await kv.set(KV.graphEdgeHistory, existingEdge.id, updatedOld); + + edge.version = (existingEdge.version || 1) + 1; + } + + await kv.set(KV.graphEdges, edge.id, edge); + existingEdges.push(edge); + } + + logger.info("Temporal graph extraction complete", { + nodes: nodes.length, + edges: edges.length, + }); + return { + success: true, + nodesAdded: nodes.length, + edgesAdded: edges.length, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error("Temporal graph extraction failed", { error: msg }); + return { success: false, error: msg }; + } + }, + ); + + sdk.registerFunction("mem::temporal-query", + async (data: { + entityName: string; + asOf?: string; + includeHistory?: boolean; + }): Promise<TemporalState | { error: string }> => { + const allNodes = await kv.list<GraphNode>(KV.graphNodes); + const allEdges = await kv.list<GraphEdge>(KV.graphEdges); + + const entity = allNodes.find( + (n) => + n.name.toLowerCase() === data.entityName.toLowerCase() || + (n.aliases && + n.aliases.some( + (a) => + a.toLowerCase() === data.entityName.toLowerCase(), + )), + ); + + if (!entity) { + return { error: `Entity "${data.entityName}" not found` } as any; + } + + const relatedEdges = allEdges.filter( + (e) => e.sourceNodeId === entity.id || e.targetNodeId === entity.id, + ); + + const historicalEdges = await kv + .list<GraphEdge>(KV.graphEdgeHistory) + .catch(() => [] as GraphEdge[]); + const entityHistory = historicalEdges.filter( + (e) => e.sourceNodeId === entity.id || e.targetNodeId === entity.id, + ); + + const allEntityEdges = [...relatedEdges, ...entityHistory]; + + if (data.asOf) { + const asOfTime = new Date(data.asOf).getTime(); + const validEdges = allEntityEdges.filter((e) => { + const commitTime = new Date( + e.tcommit || e.createdAt, + ).getTime(); + if (commitTime > asOfTime) return false; + if (e.tvalid) { + const validTime = new Date(e.tvalid).getTime(); + if (validTime > asOfTime) return false; + } + if (e.tvalidEnd) { + const endTime = new Date(e.tvalidEnd).getTime(); + if (endTime < asOfTime) return false; + } + return true; + }); + + const currentEdges = getLatestByKey(validEdges); + const historical = data.includeHistory ? validEdges : []; + + return { + entity, + currentEdges, + historicalEdges: historical, + timeline: buildTimeline(allEntityEdges), + }; + } + + const currentEdges = relatedEdges.filter( + (e) => e.isLatest !== false, + ); + + return { + entity, + currentEdges, + historicalEdges: data.includeHistory ? entityHistory : [], + timeline: buildTimeline(allEntityEdges), + }; + }, + ); + + sdk.registerFunction("mem::differential-state", + async (data: { + entityName: string; + from?: string; + to?: string; + }) => { + const allNodes = await kv.list<GraphNode>(KV.graphNodes); + const allEdges = await kv.list<GraphEdge>(KV.graphEdges); + const historicalEdges = await kv + .list<GraphEdge>(KV.graphEdgeHistory) + .catch(() => [] as GraphEdge[]); + + const entity = allNodes.find( + (n) => n.name.toLowerCase() === data.entityName.toLowerCase(), + ); + if (!entity) return { error: "Entity not found" }; + + const allEntityEdges = [ + ...allEdges.filter( + (e) => + e.sourceNodeId === entity.id || e.targetNodeId === entity.id, + ), + ...historicalEdges.filter( + (e) => + e.sourceNodeId === entity.id || e.targetNodeId === entity.id, + ), + ]; + + allEntityEdges.sort( + (a, b) => + new Date(a.tcommit || a.createdAt).getTime() - + new Date(b.tcommit || b.createdAt).getTime(), + ); + + const fromTime = data.from + ? new Date(data.from).getTime() + : 0; + const toTime = data.to + ? new Date(data.to).getTime() + : Date.now(); + + const filtered = allEntityEdges.filter((e) => { + const t = new Date(e.tcommit || e.createdAt).getTime(); + return t >= fromTime && t <= toTime; + }); + + const changes = filtered.map((e) => ({ + type: e.type, + target: + e.sourceNodeId === entity.id + ? e.targetNodeId + : e.sourceNodeId, + validFrom: e.tvalid || e.createdAt, + validTo: e.tvalidEnd, + reasoning: e.context?.reasoning, + sentiment: e.context?.sentiment, + version: e.version || 1, + isLatest: e.isLatest !== false, + })); + + return { + entity: entity.name, + totalChanges: changes.length, + changes, + }; + }, + ); +} + +function getLatestByKey(edges: GraphEdge[]): GraphEdge[] { + const byKey = new Map<string, GraphEdge>(); + for (const e of edges) { + const key = `${e.sourceNodeId}|${e.targetNodeId}|${e.type}`; + const existing = byKey.get(key); + if ( + !existing || + new Date(e.tcommit || e.createdAt).getTime() > + new Date(existing.tcommit || existing.createdAt).getTime() + ) { + byKey.set(key, e); + } + } + return Array.from(byKey.values()); +} + +function buildTimeline( + edges: GraphEdge[], +): Array<{ + edge: GraphEdge; + validFrom: string; + validTo?: string; + context?: EdgeContext; +}> { + const sorted = [...edges].sort( + (a, b) => + new Date(a.tcommit || a.createdAt).getTime() - + new Date(b.tcommit || b.createdAt).getTime(), + ); + + return sorted.map((e) => ({ + edge: e, + validFrom: e.tvalid || e.createdAt, + validTo: e.tvalidEnd, + context: e.context, + })); +} diff --git a/src/functions/timeline.ts b/src/functions/timeline.ts new file mode 100644 index 0000000..e2244fe --- /dev/null +++ b/src/functions/timeline.ts @@ -0,0 +1,139 @@ +import type { ISdk } from "iii-sdk"; +import type { + CompressedObservation, + Session, + TimelineEntry, +} from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAccessBatch } from "./access-tracker.js"; +import { logger } from "../logger.js"; + +export function registerTimelineFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::timeline", + async (data: { + anchor: string; + project?: string; + before?: number; + after?: number; + }) => { + const before = Math.max(0, Math.floor(data.before ?? 5)); + const after = Math.max(0, Math.floor(data.after ?? 5)); + + if (!data.anchor || typeof data.anchor !== "string") { + return { entries: [], anchor: data.anchor, reason: "invalid_anchor" }; + } + + let anchorTime: number; + const isoPattern = /^\d{4}-\d{2}-\d{2}/; + if (isoPattern.test(data.anchor)) { + anchorTime = new Date(data.anchor).getTime(); + if (isNaN(anchorTime)) { + return { entries: [], anchor: data.anchor, reason: "invalid_date" }; + } + } else { + const searchResults = await findByKeyword( + kv, + data.anchor, + data.project, + ); + if (searchResults.length === 0) { + return { entries: [], anchor: data.anchor, reason: "no_match" }; + } + anchorTime = new Date(searchResults[0].timestamp).getTime(); + } + + const sessions = await kv.list<Session>(KV.sessions); + const filtered = data.project + ? sessions.filter((s) => s.project === data.project) + : sessions; + + const allObs: Array<CompressedObservation & { sid: string }> = []; + for (const session of filtered) { + const observations = await kv.list<CompressedObservation>( + KV.observations(session.id), + ); + for (const obs of observations) { + if (obs.title && obs.timestamp) { + allObs.push({ ...obs, sid: session.id }); + } + } + } + + allObs.sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + let anchorIdx = 0; + let minDist = Infinity; + for (let i = 0; i < allObs.length; i++) { + const dist = Math.abs( + new Date(allObs[i].timestamp).getTime() - anchorTime, + ); + if (dist < minDist) { + minDist = dist; + anchorIdx = i; + } + } + + const startIdx = Math.max(0, anchorIdx - before); + const endIdx = Math.min(allObs.length - 1, anchorIdx + after); + const entries: TimelineEntry[] = []; + + for (let i = startIdx; i <= endIdx; i++) { + const obs = allObs[i]; + const { sid, ...observation } = obs; + entries.push({ + observation, + sessionId: sid, + relativePosition: i - anchorIdx, + }); + } + + void recordAccessBatch( + kv, + entries.map((e) => e.observation.id), + ); + + logger.info("Timeline retrieved", { + anchor: data.anchor, + entries: entries.length, + }); + return { entries, anchorIndex: anchorIdx - startIdx }; + }, + ); +} + +async function findByKeyword( + kv: StateKV, + keyword: string, + project?: string, +): Promise<CompressedObservation[]> { + const sessions = await kv.list<Session>(KV.sessions); + const filtered = project + ? sessions.filter((s) => s.project === project) + : sessions; + + const lower = keyword.toLowerCase(); + const matches: CompressedObservation[] = []; + + for (const session of filtered) { + const observations = await kv.list<CompressedObservation>( + KV.observations(session.id), + ); + for (const obs of observations) { + if ( + obs.title?.toLowerCase().includes(lower) || + obs.narrative?.toLowerCase().includes(lower) || + obs.concepts?.some((c) => c.toLowerCase().includes(lower)) + ) { + matches.push(obs); + } + } + } + + return matches.sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); +} diff --git a/src/functions/verify.ts b/src/functions/verify.ts new file mode 100644 index 0000000..a6e393f --- /dev/null +++ b/src/functions/verify.ts @@ -0,0 +1,116 @@ +import type { ISdk } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import type { + Memory, + CompressedObservation, + Session, +} from "../types.js"; + +export function registerVerifyFunction(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction("mem::verify", + async (data: { id: string }) => { + if (!data.id || typeof data.id !== "string") { + return { success: false, error: "id is required" }; + } + + const memory = await kv.get<Memory>(KV.memories, data.id); + if (memory) { + const observationIds = memory.sourceObservationIds || []; + const observations: Array<{ + observation: CompressedObservation; + session?: Session; + }> = []; + + for (const obsId of observationIds) { + const obs = await findObservation(kv, obsId, memory.sessionIds); + if (obs) { + const session = await kv.get<Session>(KV.sessions, obs.sessionId); + observations.push({ observation: obs, session: session || undefined }); + } + } + + return { + success: true, + type: "memory", + memory: { + id: memory.id, + title: memory.title, + type: memory.type, + version: memory.version, + strength: memory.strength, + isLatest: memory.isLatest, + createdAt: memory.createdAt, + updatedAt: memory.updatedAt, + supersedes: memory.supersedes, + parentId: memory.parentId, + }, + citations: observations.map((o) => ({ + observationId: o.observation.id, + title: o.observation.title, + type: o.observation.type, + confidence: o.observation.confidence, + timestamp: o.observation.timestamp, + sessionId: o.observation.sessionId, + sessionProject: o.session?.project, + sessionStatus: o.session?.status, + })), + citationCount: observations.length, + }; + } + + const obs = await findObservation(kv, data.id); + if (obs) { + const session = await kv.get<Session>(KV.sessions, obs.sessionId); + return { + success: true, + type: "observation", + observation: { + id: obs.id, + title: obs.title, + type: obs.type, + confidence: obs.confidence, + importance: obs.importance, + timestamp: obs.timestamp, + sessionId: obs.sessionId, + }, + session: session + ? { + id: session.id, + project: session.project, + status: session.status, + startedAt: session.startedAt, + } + : null, + citationCount: 0, + citations: [], + }; + } + + return { success: false, error: "not found" }; + }, + ); +} + +async function findObservation( + kv: StateKV, + obsId: string, + hintSessionIds?: string[], +): Promise<CompressedObservation | null> { + if (hintSessionIds) { + for (const sid of hintSessionIds) { + const obs = await kv.get<CompressedObservation>(KV.observations(sid), obsId); + if (obs) return obs; + } + } + const sessions = await kv.list<Session>(KV.sessions); + for (const session of sessions) { + if (hintSessionIds?.includes(session.id)) continue; + const obs = await kv.get<CompressedObservation>( + KV.observations(session.id), + obsId, + ); + if (obs) return obs; + } + return null; +} diff --git a/src/functions/vision-search.ts b/src/functions/vision-search.ts new file mode 100644 index 0000000..844ec9d --- /dev/null +++ b/src/functions/vision-search.ts @@ -0,0 +1,147 @@ +import type { ISdk } from "iii-sdk"; +import type { EmbeddingProvider } from "../types.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { isManagedImagePath } from "../utils/image-store.js"; +import { recordAudit } from "./audit.js"; +import { logger } from "../logger.js"; + +interface StoredEmbedding { + imageRef: string; + vector: number[]; + modelName: string; + dimensions: number; + updatedAt: string; + sessionId?: string; + observationId?: string; +} + +export function registerVisionSearchFunctions( + sdk: ISdk, + kv: StateKV, + imageProvider: EmbeddingProvider | null, +): void { + sdk.registerFunction( + "mem::vision-embed", + async (data: { + imageRef: string; + sessionId?: string; + observationId?: string; + }) => { + if (!imageProvider?.embedImage) { + return { success: false, error: "image embeddings disabled (set AGENTMEMORY_IMAGE_EMBEDDINGS=true)" }; + } + if (!data?.imageRef || typeof data.imageRef !== "string") { + return { success: false, error: "imageRef required" }; + } + if (!isManagedImagePath(data.imageRef)) { + return { success: false, error: "imageRef must point to a file under the managed image store" }; + } + const refCount = await kv.get<number>(KV.imageRefs, data.imageRef); + if (!refCount || Number(refCount) < 1) { + return { success: false, error: "imageRef not registered in mem:image-refs" }; + } + try { + const vec = await imageProvider.embedImage(data.imageRef); + const stored: StoredEmbedding = { + imageRef: data.imageRef, + vector: Array.from(vec), + modelName: imageProvider.name, + dimensions: imageProvider.dimensions, + updatedAt: new Date().toISOString(), + sessionId: data.sessionId, + observationId: data.observationId, + }; + await kv.set(KV.imageEmbeddings, data.imageRef, stored); + await recordAudit(kv, "vision_embed", "mem::vision-embed", [data.imageRef], { + modelName: imageProvider.name, + dimensions: stored.dimensions, + sessionId: data.sessionId, + observationId: data.observationId, + }); + return { success: true, imageRef: data.imageRef, dimensions: stored.dimensions }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("vision-embed failed", { imageRef: data.imageRef, error: msg }); + return { success: false, error: msg }; + } + }, + ); + + sdk.registerFunction( + "mem::vision-search", + async (data: { + queryText?: string; + queryImageRef?: string; + queryImageBase64?: string; + topK?: number; + sessionId?: string; + }) => { + if (!imageProvider?.embedImage) { + return { success: false, error: "image embeddings disabled (set AGENTMEMORY_IMAGE_EMBEDDINGS=true)" }; + } + const requestedTopK = + typeof data?.topK === "number" && Number.isFinite(data.topK) + ? Math.trunc(data.topK) + : 10; + const topK = Math.min(50, Math.max(1, requestedTopK)); + + let queryVec: Float32Array | null = null; + try { + if (data?.queryText) { + queryVec = await imageProvider.embed(data.queryText); + } else if (data?.queryImageBase64) { + const b64 = data.queryImageBase64.startsWith("data:") + ? data.queryImageBase64 + : `data:image/png;base64,${data.queryImageBase64}`; + queryVec = await imageProvider.embedImage(b64); + } else if (data?.queryImageRef) { + if (!isManagedImagePath(data.queryImageRef)) { + return { success: false, error: "queryImageRef must point to a file under the managed image store" }; + } + const refCount = await kv.get<number>(KV.imageRefs, data.queryImageRef); + if (!refCount || Number(refCount) < 1) { + return { success: false, error: "queryImageRef not registered in mem:image-refs" }; + } + queryVec = await imageProvider.embedImage(data.queryImageRef); + } else { + return { success: false, error: "queryText, queryImageRef, or queryImageBase64 required" }; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { success: false, error: `query embed failed: ${msg}` }; + } + + if (!queryVec) return { success: false, error: "failed to build query vector" }; + + const stored = await kv.list<StoredEmbedding>(KV.imageEmbeddings); + const filtered = data?.sessionId + ? stored.filter((s) => s.sessionId === data.sessionId) + : stored; + + const scored = filtered.map((s) => ({ + imageRef: s.imageRef, + score: cosine(queryVec!, s.vector), + sessionId: s.sessionId, + observationId: s.observationId, + updatedAt: s.updatedAt, + })); + scored.sort((a, b) => b.score - a.score); + return { success: true, results: scored.slice(0, topK), total: scored.length }; + }, + ); +} + +function cosine(a: Float32Array, b: number[]): number { + if (a.length !== b.length) return 0; + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; +} diff --git a/src/functions/working-memory.ts b/src/functions/working-memory.ts new file mode 100644 index 0000000..127195e --- /dev/null +++ b/src/functions/working-memory.ts @@ -0,0 +1,254 @@ +import type { ISdk } from "iii-sdk"; +import type { Memory, CompressedObservation, ContextBlock } from "../types.js"; +import { KV, generateId } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { recordAudit } from "./audit.js"; +import { recordAccessBatch } from "./access-tracker.js"; +import { logger } from "../logger.js"; + +const CORE_SCOPE = "mem:core-memory"; + +interface CoreMemoryEntry { + id: string; + content: string; + importance: number; + pinned: boolean; + accessCount: number; + lastAccessedAt: string; + createdAt: string; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 3); +} + +function scoreEntry(entry: CoreMemoryEntry, now: number): number { + const recencyMs = now - new Date(entry.lastAccessedAt).getTime(); + const recencyDays = recencyMs / (1000 * 60 * 60 * 24); + const recencyScore = 1 / (1 + recencyDays * 0.1); + const accessScore = Math.log2(entry.accessCount + 1) / 10; + const importanceScore = entry.importance / 10; + return importanceScore * 0.5 + recencyScore * 0.3 + accessScore * 0.2; +} + +export function registerWorkingMemoryFunctions( + sdk: ISdk, + kv: StateKV, + tokenBudget: number, +): void { + sdk.registerFunction("mem::core-add", + async (data: { + content: string; + importance?: number; + pinned?: boolean; + }) => { + if (!data?.content?.trim()) { + return { success: false, error: "content is required" }; + } + const now = new Date().toISOString(); + const entry: CoreMemoryEntry = { + id: generateId("core"), + content: data.content.trim(), + importance: Math.min(10, Math.max(1, data.importance ?? 7)), + pinned: data.pinned ?? false, + accessCount: 0, + lastAccessedAt: now, + createdAt: now, + }; + await kv.set(CORE_SCOPE, entry.id, entry); + + try { + await recordAudit(kv, "core_add", "mem::core-add", [entry.id], { + content: entry.content.slice(0, 100), + importance: entry.importance, + pinned: entry.pinned, + }); + } catch {} + + return { success: true, id: entry.id }; + }, + ); + + sdk.registerFunction("mem::core-remove", + async (data: { id: string }) => { + if (!data?.id) return { success: false, error: "id is required" }; + await kv.delete(CORE_SCOPE, data.id); + + try { + await recordAudit(kv, "core_remove", "mem::core-remove", [data.id], {}); + } catch {} + + return { success: true }; + }, + ); + + sdk.registerFunction("mem::core-list", + async () => { + const entries = await kv.list<CoreMemoryEntry>(CORE_SCOPE); + entries.sort((a, b) => b.importance - a.importance); + return { + success: true, + entries, + totalTokens: entries.reduce( + (sum, e) => sum + estimateTokens(e.content), + 0, + ), + }; + }, + ); + + sdk.registerFunction("mem::working-context", + async (data: { budget?: number }) => { + const budget = data.budget || tokenBudget; + const now = Date.now(); + let usedTokens = 0; + + const coreEntries = await kv.list<CoreMemoryEntry>(CORE_SCOPE); + + const pinned = coreEntries.filter((e) => e.pinned); + const unpinned = coreEntries + .filter((e) => !e.pinned) + .sort((a, b) => scoreEntry(b, now) - scoreEntry(a, now)); + + const coreLines: string[] = []; + const coreBudget = Math.floor(budget * 0.3); + const accessUpdates: Array<{ id: string; entry: CoreMemoryEntry }> = []; + const accessTimestamp = new Date().toISOString(); + + for (const entry of [...pinned, ...unpinned]) { + const tokens = estimateTokens(entry.content); + if (usedTokens + tokens > coreBudget && !entry.pinned) continue; + coreLines.push(`- ${entry.content}`); + usedTokens += tokens; + + entry.accessCount++; + entry.lastAccessedAt = accessTimestamp; + accessUpdates.push({ id: entry.id, entry }); + } + + Promise.allSettled( + accessUpdates.map(({ id, entry }) => kv.set(CORE_SCOPE, id, entry)), + ).catch(() => {}); + + const archivalLines: string[] = []; + + const memories = await kv.list<Memory>(KV.memories); + const active = memories + .filter((m) => m.isLatest !== false) + .sort((a, b) => { + const strengthDiff = b.strength - a.strength; + if (Math.abs(strengthDiff) > 0.2) return strengthDiff; + return ( + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + }); + + const archivalIds: string[] = []; + for (const mem of active) { + const tokens = estimateTokens(mem.content); + if (usedTokens + tokens > budget) continue; + archivalLines.push(`- [${mem.type}] ${mem.title}: ${mem.content}`); + archivalIds.push(mem.id); + usedTokens += tokens; + } + + void recordAccessBatch(kv, archivalIds); + + const pagedOut = active.length - archivalLines.length; + + const sections: string[] = []; + if (coreLines.length > 0) { + sections.push(`## Core Memory\n${coreLines.join("\n")}`); + } + if (archivalLines.length > 0) { + sections.push(`## Archival Memory\n${archivalLines.join("\n")}`); + } + if (pagedOut > 0) { + sections.push( + `_${pagedOut} memories paged to archival (use mem::search to retrieve)_`, + ); + } + + const context = sections.join("\n\n"); + + logger.info("Working context built", { + coreEntries: coreLines.length, + archivalEntries: archivalLines.length, + pagedOut, + tokens: usedTokens, + budget, + }); + + return { + success: true, + context, + coreEntries: coreLines.length, + archivalEntries: archivalLines.length, + pagedOut, + tokens: usedTokens, + budget, + }; + }, + ); + + sdk.registerFunction("mem::auto-page", + async (data: { budget?: number }) => { + const budget = data?.budget || tokenBudget; + const coreBudget = Math.floor(budget * 0.3); + + const entries = await kv.list<CoreMemoryEntry>(CORE_SCOPE); + let totalTokens = entries.reduce( + (sum, e) => sum + estimateTokens(e.content), + 0, + ); + + if (totalTokens <= coreBudget) { + return { success: true, paged: 0, totalTokens, budget: coreBudget }; + } + + const now = Date.now(); + const unpinned = entries + .filter((e) => !e.pinned) + .sort((a, b) => scoreEntry(a, now) - scoreEntry(b, now)); + + let paged = 0; + const pagedIds: string[] = []; + for (const entry of unpinned) { + if (totalTokens <= coreBudget) break; + const tokens = estimateTokens(entry.content); + + const archivalMemory: Memory = { + id: generateId("mem"), + createdAt: entry.createdAt, + updatedAt: new Date().toISOString(), + type: "fact", + title: entry.content.slice(0, 80), + content: entry.content, + concepts: [], + files: [], + sessionIds: [], + strength: entry.importance / 10, + version: 1, + isLatest: true, + }; + await kv.set(KV.memories, archivalMemory.id, archivalMemory); + await kv.delete(CORE_SCOPE, entry.id); + + totalTokens -= tokens; + paged++; + pagedIds.push(entry.id); + } + + if (paged > 0) { + try { + await recordAudit(kv, "auto_page", "mem::auto-page", pagedIds, { + paged, + budget: coreBudget, + }); + } catch {} + } + + return { success: true, paged, totalTokens, budget: coreBudget }; + }, + ); +} diff --git a/src/health/monitor.ts b/src/health/monitor.ts new file mode 100644 index 0000000..953c94a --- /dev/null +++ b/src/health/monitor.ts @@ -0,0 +1,111 @@ +import type { ISdk } from "iii-sdk"; +import type { HealthSnapshot } from "../types.js"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import { evaluateHealth } from "./thresholds.js"; + +export function registerHealthMonitor( + sdk: ISdk, + kv: StateKV, +): { stop: () => void } { + let connectionState = "connected"; + let prevCpuUsage = process.cpuUsage(); + let prevCpuTime = Date.now(); + + if (typeof sdk.on === "function") { + sdk.on("connection_state", (state?: unknown) => { + connectionState = state as string; + }); + } + + async function collectHealth(): Promise<HealthSnapshot> { + const mem = process.memoryUsage(); + const currentCpu = process.cpuUsage(); + const now = Date.now(); + const uptime = process.uptime(); + + const elapsedMs = now - prevCpuTime; + const userDelta = currentCpu.user - prevCpuUsage.user; + const systemDelta = currentCpu.system - prevCpuUsage.system; + const cpuPercent = + elapsedMs > 0 ? ((userDelta + systemDelta) / 1000 / elapsedMs) * 100 : 0; + prevCpuUsage = currentCpu; + prevCpuTime = now; + + const startMark = performance.now(); + await new Promise((resolve) => setImmediate(resolve)); + const eventLoopLagMs = performance.now() - startMark; + + let workers: HealthSnapshot["workers"] = []; + try { + const result = await sdk.trigger< + unknown, + { workers?: HealthSnapshot["workers"] } + >({ function_id: "engine::workers::list", payload: {} }); + if (result?.workers) workers = result.workers; + } catch {} + + const KV_PROBE_TIMEOUT = 5000; + let kvConnectivity: { status: string; latencyMs?: number; error?: string }; + const kvStart = performance.now(); + try { + await Promise.race([ + (async () => { + await kv.set(KV.health, "_probe", { ts: Date.now() }); + await kv.get(KV.health, "_probe"); + })(), + new Promise<never>((_, reject) => + setTimeout(() => reject(new Error("timeout")), KV_PROBE_TIMEOUT), + ), + ]); + kvConnectivity = { status: "ok", latencyMs: Math.round((performance.now() - kvStart) * 100) / 100 }; + } catch { + kvConnectivity = { status: "error", error: "kv_probe_failed", latencyMs: Math.round((performance.now() - kvStart) * 100) / 100 }; + } + + const snapshot: HealthSnapshot = { + connectionState, + workers, + memory: { + heapUsed: mem.heapUsed, + heapTotal: mem.heapTotal, + rss: mem.rss, + external: mem.external, + }, + cpu: { + userMicros: currentCpu.user, + systemMicros: currentCpu.system, + percent: Math.round(cpuPercent * 100) / 100, + }, + eventLoopLagMs, + uptimeSeconds: uptime, + kvConnectivity, + status: "healthy", + alerts: [], + }; + + const evaluated = evaluateHealth(snapshot); + snapshot.status = evaluated.status; + snapshot.alerts = evaluated.alerts; + snapshot.notes = evaluated.notes; + + await kv.set(KV.health, "latest", snapshot).catch(() => {}); + return snapshot; + } + + collectHealth().catch(() => {}); + const interval = setInterval(() => { + collectHealth().catch(() => {}); + }, 30_000); + interval.unref(); + + return { + stop: () => clearInterval(interval), + }; +} + +export async function getLatestHealth( + kv: StateKV, +): Promise<HealthSnapshot | null> { + return kv.get<HealthSnapshot>(KV.health, "latest"); +} diff --git a/src/health/thresholds.ts b/src/health/thresholds.ts new file mode 100644 index 0000000..7279afa --- /dev/null +++ b/src/health/thresholds.ts @@ -0,0 +1,81 @@ +import type { HealthSnapshot } from "../types.js"; + +interface ThresholdConfig { + eventLoopLagWarnMs: number; + eventLoopLagCriticalMs: number; + cpuWarnPercent: number; + cpuCriticalPercent: number; + memoryWarnPercent: number; + memoryCriticalPercent: number; + memoryRssFloorBytes: number; +} + +const DEFAULTS: ThresholdConfig = { + eventLoopLagWarnMs: 100, + eventLoopLagCriticalMs: 500, + cpuWarnPercent: 80, + cpuCriticalPercent: 90, + memoryWarnPercent: 80, + memoryCriticalPercent: 95, + memoryRssFloorBytes: 512 * 1024 * 1024, +}; + +export function evaluateHealth( + snapshot: HealthSnapshot, + config: Partial<ThresholdConfig> = {}, +): { status: "healthy" | "degraded" | "critical"; alerts: string[]; notes: string[] } { + const cfg = { ...DEFAULTS, ...config }; + const alerts: string[] = []; + const notes: string[] = []; + let critical = false; + let degraded = false; + + if ( + snapshot.connectionState === "disconnected" || + snapshot.connectionState === "failed" + ) { + alerts.push(`connection_${snapshot.connectionState}`); + critical = true; + } else if (snapshot.connectionState === "reconnecting") { + alerts.push("connection_reconnecting"); + degraded = true; + } + + if (snapshot.eventLoopLagMs > cfg.eventLoopLagCriticalMs) { + alerts.push( + `event_loop_lag_critical_${Math.round(snapshot.eventLoopLagMs)}ms`, + ); + critical = true; + } else if (snapshot.eventLoopLagMs > cfg.eventLoopLagWarnMs) { + alerts.push(`event_loop_lag_warn_${Math.round(snapshot.eventLoopLagMs)}ms`); + degraded = true; + } + + if (snapshot.cpu.percent > cfg.cpuCriticalPercent) { + alerts.push(`cpu_critical_${Math.round(snapshot.cpu.percent)}%`); + critical = true; + } else if (snapshot.cpu.percent > cfg.cpuWarnPercent) { + alerts.push(`cpu_warn_${Math.round(snapshot.cpu.percent)}%`); + degraded = true; + } + + const memPercent = + snapshot.memory.heapTotal > 0 + ? (snapshot.memory.heapUsed / snapshot.memory.heapTotal) * 100 + : 0; + const rss = snapshot.memory.rss ?? 0; + const rssAboveFloor = rss >= cfg.memoryRssFloorBytes; + const memMb = Math.round(rss / (1024 * 1024)); + if (memPercent > cfg.memoryCriticalPercent && rssAboveFloor) { + alerts.push(`memory_critical_${Math.round(memPercent)}%_rss${memMb}mb`); + critical = true; + } else if (memPercent > cfg.memoryWarnPercent && rssAboveFloor) { + alerts.push(`memory_warn_${Math.round(memPercent)}%_rss${memMb}mb`); + degraded = true; + } else if (memPercent > cfg.memoryWarnPercent) { + notes.push(`memory_heap_tight_${Math.round(memPercent)}%_rss${memMb}mb`); + } + + const status = critical ? "critical" : degraded ? "degraded" : "healthy"; + return { status, alerts, notes }; +} diff --git a/src/hooks/_project.ts b/src/hooks/_project.ts new file mode 100644 index 0000000..35364ea --- /dev/null +++ b/src/hooks/_project.ts @@ -0,0 +1,20 @@ +import { execSync } from "node:child_process"; +import { basename } from "node:path"; + +// Resolution order: AGENTMEMORY_PROJECT_NAME env → git toplevel basename → cwd basename. +export function resolveProject(cwd?: string): string { + const explicit = process.env["AGENTMEMORY_PROJECT_NAME"]; + if (explicit && explicit.trim()) return explicit.trim(); + const dir = cwd && cwd.trim() ? cwd : process.cwd(); + try { + const top = execSync("git rev-parse --show-toplevel", { + cwd: dir, + stdio: ["ignore", "pipe", "ignore"], + timeout: 500, + }) + .toString() + .trim(); + if (top) return basename(top); + } catch {} + return basename(dir); +} diff --git a/src/hooks/notification.ts b/src/hooks/notification.ts new file mode 100644 index 0000000..9ad7704 --- /dev/null +++ b/src/hooks/notification.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + const notificationType = data.notification_type ?? data.notificationType; + if (notificationType !== "permission_prompt") return; + + const rawSessionId = data.session_id ?? data.sessionId; + const sessionId = + typeof rawSessionId === "string" && rawSessionId.length > 0 + ? rawSessionId + : "unknown"; + + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "notification", + sessionId, + project: resolveProject(data.cwd as string | undefined), + cwd: (data.cwd as string | undefined) || process.cwd(), + timestamp: new Date().toISOString(), + data: { + notification_type: notificationType, + title: data.title, + message: data.message, + }, + }), + signal: AbortSignal.timeout(2000), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} + +main(); diff --git a/src/hooks/post-commit.ts b/src/hooks/post-commit.ts new file mode 100644 index 0000000..30c54a9 --- /dev/null +++ b/src/hooks/post-commit.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; +const TIMEOUT_MS = 1500; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function git(args: string[], cwd: string): Promise<string | null> { + try { + const { stdout } = await exec("git", args, { cwd, timeout: 1500 }); + return stdout.trim(); + } catch { + return null; + } +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown> = {}; + if (input.trim()) { + try { + data = JSON.parse(input); + } catch { + // Direct invocation from .git/hooks/post-commit may pass no stdin. + } + } + + if (isSdkChildContext(data)) return; + + const cwd = + (data.cwd as string) || + process.env["AGENTMEMORY_CWD"] || + process.cwd(); + const sessionId = + (data.session_id as string) || + process.env["AGENTMEMORY_SESSION_ID"] || + undefined; + + const sha = + process.env["AGENTMEMORY_COMMIT_SHA"] || + (await git(["rev-parse", "HEAD"], cwd)); + if (!sha) return; + + const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd); + const repo = await git(["config", "--get", "remote.origin.url"], cwd); + const message = await git(["log", "-1", "--pretty=%B", sha], cwd); + const author = await git(["log", "-1", "--pretty=%an <%ae>", sha], cwd); + const authoredAt = await git(["log", "-1", "--pretty=%aI", sha], cwd); + const filesRaw = await git( + ["diff-tree", "--no-commit-id", "--name-only", "-r", sha], + cwd, + ); + const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : undefined; + + const body = { + sessionId, + sha, + branch: branch || undefined, + repo: repo || undefined, + message: message || undefined, + author: author || undefined, + authoredAt: authoredAt || undefined, + files, + }; + + try { + await fetch(`${REST_URL}/agentmemory/session/commit`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch { + // best-effort + } +} + +main(); diff --git a/src/hooks/post-tool-failure.ts b/src/hooks/post-tool-failure.ts new file mode 100644 index 0000000..f0295b3 --- /dev/null +++ b/src/hooks/post-tool-failure.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + if (data.is_interrupt || data.isInterrupt) return; + + const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; + const toolName = data.tool_name ?? data.toolName; + const toolInput = data.tool_input ?? data.toolArgs; + const error = data.error ?? data.errorMessage; + + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "post_tool_failure", + sessionId, + project: resolveProject(data.cwd as string | undefined), + cwd: (data.cwd as string | undefined) || process.cwd(), + timestamp: new Date().toISOString(), + data: { + tool_name: toolName, + tool_input: + typeof toolInput === "string" + ? toolInput.slice(0, 4000) + : JSON.stringify(toolInput ?? "").slice(0, 4000), + error: + typeof error === "string" + ? error.slice(0, 4000) + : JSON.stringify(error ?? "").slice(0, 4000), + }, + }), + signal: AbortSignal.timeout(3000), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} + +main(); diff --git a/src/hooks/post-tool-use.ts b/src/hooks/post-tool-use.ts new file mode 100644 index 0000000..c68a773 --- /dev/null +++ b/src/hooks/post-tool-use.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; + const toolName = data.tool_name ?? data.toolName; + const toolInput = data.tool_input ?? data.toolArgs; + + const { imageData, cleanOutput } = extractImageData(toolOutput(data)); + + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "post_tool_use", + sessionId, + project: resolveProject(data.cwd as string | undefined), + cwd: (data.cwd as string | undefined) || process.cwd(), + timestamp: new Date().toISOString(), + data: { + tool_name: toolName, + tool_input: toolInput, + tool_output: truncate(cleanOutput, 8000), + ...(imageData ? { image_data: imageData } : {}), + }, + }), + signal: AbortSignal.timeout(3000), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} + +function toolOutput(data: Record<string, unknown>): unknown { + if (data.tool_response !== undefined) return data.tool_response; + if (data.tool_output !== undefined) return data.tool_output; + const result = data.tool_result ?? data.toolResult; + if (typeof result === "object" && result !== null) { + const obj = result as Record<string, unknown>; + return obj.text_result_for_llm ?? obj.textResultForLlm ?? result; + } + return result; +} + +function isBase64Image(val: unknown): val is string { + return typeof val === "string" && ( + val.startsWith("data:image/") || + val.startsWith("iVBORw0KGgo") || + val.startsWith("/9j/") + ); +} + +function extractImageData(output: unknown): { imageData: string | undefined; cleanOutput: unknown } { + if (isBase64Image(output)) { + return { imageData: output, cleanOutput: "[image data extracted]" }; + } + + if (typeof output === "object" && output !== null && !Array.isArray(output)) { + const obj = output as Record<string, unknown>; + let imageData: string | undefined; + const clean: Record<string, unknown> = {}; + + for (const [key, val] of Object.entries(obj)) { + if (!imageData && isBase64Image(val)) { + imageData = val; + clean[key] = "[image data extracted]"; + } else { + clean[key] = val; + } + } + + return { imageData, cleanOutput: clean }; + } + + return { imageData: undefined, cleanOutput: output }; +} + +function truncate(value: unknown, max: number): unknown { + if (typeof value === "string" && value.length > max) { + return value.slice(0, max) + "\n[...truncated]"; + } + if (typeof value === "object" && value !== null) { + const str = JSON.stringify(value); + if (str.length > max) return str.slice(0, max) + "...[truncated]"; + return value; + } + return value; +} + +main(); diff --git a/src/hooks/pre-compact.ts b/src/hooks/pre-compact.ts new file mode 100644 index 0000000..8bb2660 --- /dev/null +++ b/src/hooks/pre-compact.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; + const project = resolveProject(data.cwd as string | undefined); + + if (process.env["CLAUDE_MEMORY_BRIDGE"] === "true") { + try { + await fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({}), + signal: AbortSignal.timeout(5000), + }); + } catch { + // best-effort + } + } + + try { + const res = await fetch(`${REST_URL}/agentmemory/context`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId, project, budget: 1500 }), + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + const result = (await res.json()) as { context?: string }; + if (result.context) { + process.stdout.write(result.context); + } + } + } catch { + // best effort -- don't block compaction + } +} + +main(); diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts new file mode 100644 index 0000000..277d779 --- /dev/null +++ b/src/hooks/pre-tool-use.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +// Pre-tool-use enrichment hook. +// +// THIS HOOK IS A NO-OP BY DEFAULT AS OF 0.8.10 (#143). Previously it +// fired /agentmemory/enrich on every Edit/Write/Read/Glob/Grep tool call +// and wrote up to 4000 chars of context to stdout. Claude Code reads +// PreToolUse stdout and prepends it to the model's next turn, which meant +// agentmemory was silently injecting ~1000 tokens into every tool turn +// via the user's Claude Code session. On Claude Pro that burned entire +// allocations in a handful of messages (@adrianricardo, #143). +// +// Users who explicitly want pre-tool enrichment opt in with: +// AGENTMEMORY_INJECT_CONTEXT=true in ~/.agentmemory/.env +// and restart Claude Code. Expect your session input token count to grow +// proportionally with the number of file-touching tool calls per turn. +const INJECT_CONTEXT = process.env["AGENTMEMORY_INJECT_CONTEXT"] === "true"; + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + // Default off: exit immediately so we don't even open stdin. This keeps + // Claude Code's tool-call hot path as cheap as possible. + if (!INJECT_CONTEXT) return; + + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const toolName = + typeof data.tool_name === "string" + ? data.tool_name + : typeof data.toolName === "string" + ? data.toolName + : undefined; + if (!toolName) return; + + const normalizedToolName = toolName.toLowerCase(); + const fileTools = ["edit", "write", "create", "read", "view", "glob", "grep"]; + if (!fileTools.includes(normalizedToolName)) return; + + const rawToolInput = data.tool_input ?? data.toolArgs; + const toolInput = + typeof rawToolInput === "object" && + rawToolInput !== null && + !Array.isArray(rawToolInput) + ? (rawToolInput as Record<string, unknown>) + : {}; + const files: string[] = []; + const fileKeys = + normalizedToolName === "grep" + ? ["path", "file"] + : ["file_path", "path", "file", "pattern"]; + for (const key of fileKeys) { + const val = toolInput[key]; + if (typeof val === "string" && val.length > 0) files.push(val); + } + if (files.length === 0) return; + + const terms: string[] = []; + if (normalizedToolName === "grep" || normalizedToolName === "glob") { + const pattern = toolInput["pattern"]; + if (typeof pattern === "string" && pattern.length > 0) { + terms.push(pattern); + } + } + + const rawSessionId = data.session_id || data.sessionId; + const sessionId = + typeof rawSessionId === "string" && rawSessionId.length > 0 + ? rawSessionId + : "unknown"; + const project = + typeof data.project === "string" && data.project.trim().length > 0 + ? data.project.trim() + : undefined; + + try { + const res = await fetch(`${REST_URL}/agentmemory/enrich`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sessionId, + files, + terms, + toolName, + ...(project !== undefined && { project }), + }), + signal: AbortSignal.timeout(2000), + }); + + if (res.ok) { + const result = (await res.json()) as { context?: string }; + if (result.context) { + process.stdout.write(result.context); + } + } + } catch { + // don't block tool execution + } +} + +main(); diff --git a/src/hooks/prompt-submit.ts b/src/hooks/prompt-submit.ts new file mode 100644 index 0000000..2da8ea6 --- /dev/null +++ b/src/hooks/prompt-submit.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; + + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "prompt_submit", + sessionId, + project: resolveProject(data.cwd as string | undefined), + cwd: (data.cwd as string | undefined) || process.cwd(), + timestamp: new Date().toISOString(), + data: { prompt: data.prompt ?? data.userPrompt }, + }), + signal: AbortSignal.timeout(3000), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} + +main(); diff --git a/src/hooks/sdk-guard.ts b/src/hooks/sdk-guard.ts new file mode 100644 index 0000000..8cc5f21 --- /dev/null +++ b/src/hooks/sdk-guard.ts @@ -0,0 +1,26 @@ +/** + * Recursion guard shared by every hook script. + * + * A Claude Code session spawned via @anthropic-ai/claude-agent-sdk inherits + * the same plugin hooks as the parent CC session. If any hook script in that + * child session calls back into /agentmemory/* (e.g. Stop → /summarize → + * provider.summarize() → another child session), we get unbounded recursion + * that burns tokens and fills .claude/projects/ with ghost sessions + * (#149 follow-up; see reported loop under v0.9.1). + * + * Two signals identify a SDK-child context: + * 1. AGENTMEMORY_SDK_CHILD=1 env var — set by our agent-sdk provider + * before it spawns `query()`. Inherited by child processes. + * 2. payload.entrypoint === "sdk-ts" — CC writes this into the hook + * stdin jsonl when the session was spawned by the Agent SDK. + * + * Hook scripts must call isSdkChildContext(payload) EARLY and return + * silently when it is true. + */ +export function isSdkChildContext(payload: unknown): boolean { + if (process.env.AGENTMEMORY_SDK_CHILD === "1") return true; + if (!payload || typeof payload !== "object") return false; + const p = payload as Record<string, unknown>; + if (p["entrypoint"] === "sdk-ts") return true; + return false; +} diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts new file mode 100644 index 0000000..6a9fc42 --- /dev/null +++ b/src/hooks/session-end.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; + + fetch(`${REST_URL}/agentmemory/session/end`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId }), + signal: AbortSignal.timeout(30000), + }).catch(() => {}); + + if (process.env["CONSOLIDATION_ENABLED"] === "true") { + fetch(`${REST_URL}/agentmemory/crystals/auto`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ olderThanDays: 0 }), + signal: AbortSignal.timeout(60000), + }).catch(() => {}); + + fetch(`${REST_URL}/agentmemory/consolidate-pipeline`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ tier: "all", force: true }), + signal: AbortSignal.timeout(120000), + }).catch(() => {}); + } + + if (process.env["CLAUDE_MEMORY_BRIDGE"] === "true") { + fetch(`${REST_URL}/agentmemory/claude-bridge/sync`, { + method: "POST", + headers: authHeaders(), + signal: AbortSignal.timeout(30000), + }).catch(() => {}); + } + + setTimeout(() => process.exit(0), 1500).unref(); +} + +main(); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts new file mode 100644 index 0000000..573c092 --- /dev/null +++ b/src/hooks/session-start.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +// Inlined from ./sdk-guard so each hook bundles to a single self-contained +// .mjs (matches the pattern used by every other hook entry in tsdown.config). +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +// Session-start hook. +// +// Always registers the session for observation tracking (so memories +// captured on PostToolUse get attached to the right session). Only writes +// project context to stdout — which Claude Code prepends to the very first +// turn — when AGENTMEMORY_INJECT_CONTEXT=true. Default off as of 0.8.10 +// (#143); see pre-tool-use.ts for the full explanation. +const INJECT_CONTEXT = process.env["AGENTMEMORY_INJECT_CONTEXT"] === "true"; + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +// When the server is unreachable a 5s timeout multiplies hard under +// concurrent fan-out (Slack bots, multi-agent harnesses) and becomes a +// positive feedback loop that OOM-kills iii-engine (#221). Cap tight on +// both paths and skip the await entirely when the response is unused. +const INJECT_TIMEOUT_MS = 1500; +const REGISTER_TIMEOUT_MS = 800; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const sessionId = + ((data.session_id || data.sessionId) as string) || + `ses_${Date.now().toString(36)}`; + const cwd = (data.cwd as string) || process.cwd(); + const project = resolveProject(data.cwd as string | undefined); + + const url = `${REST_URL}/agentmemory/session/start`; + const init: RequestInit = { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId, project, cwd }), + }; + + if (!INJECT_CONTEXT) { + // Pure telemetry path: caller never reads the response, so don't + // block on it. AbortSignal.timeout caps the wait the event loop + // gives the pending socket before exit. + fetch(url, { + ...init, + signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS), + }).catch(() => {}); + return; + } + + try { + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(INJECT_TIMEOUT_MS), + }); + if (res.ok) { + const result = (await res.json()) as { context?: string }; + if (result.context) { + process.stdout.write(result.context); + } + } + } catch { + // silently fail -- don't block Claude Code startup + } +} + +main(); diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts new file mode 100644 index 0000000..4103796 --- /dev/null +++ b/src/hooks/stop.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +// Inlined — see src/hooks/sdk-guard.ts for canonical version. Kept local +// per-hook so tsdown does not emit a shared hashed chunk that would churn +// the diff on every rebuild. +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) { + // Do not summarize from inside a Claude Agent SDK child session; + // would re-enter agent-sdk provider and loop (see sdk-guard.ts). + return; + } + + 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(() => {}); + + fetch(`${REST_URL}/agentmemory/session/end`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId }), + signal: AbortSignal.timeout(5000), + }).catch(() => {}); + + setTimeout(() => process.exit(0), 1500).unref(); +} + +main(); diff --git a/src/hooks/subagent-start.ts b/src/hooks/subagent-start.ts new file mode 100644 index 0000000..44c87c8 --- /dev/null +++ b/src/hooks/subagent-start.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +// Inlined from ./sdk-guard so each hook bundles to a single self-contained +// .mjs (matches the pattern used by every other hook entry in tsdown.config). +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +// Passive telemetry only — nothing reads the response, so the previous +// `await` was pure latency. Tightened from 2000ms to a defensive cap so a +// slow/unreachable server can't stack onto every concurrent subagent +// startup (#221). +const TIMEOUT_MS = 800; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; + const agentId = data.agent_id || data.agentName; + const agentType = data.agent_type || data.agentDisplayName || data.agentName; + + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "subagent_start", + sessionId, + project: resolveProject(data.cwd as string | undefined), + cwd: (data.cwd as string | undefined) || process.cwd(), + timestamp: new Date().toISOString(), + data: { + agent_id: agentId, + agent_type: agentType, + }, + }), + signal: AbortSignal.timeout(TIMEOUT_MS), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} + +main(); diff --git a/src/hooks/subagent-stop.ts b/src/hooks/subagent-stop.ts new file mode 100644 index 0000000..5a437c9 --- /dev/null +++ b/src/hooks/subagent-stop.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const sessionId = ((data.session_id || data.sessionId) as string) || "unknown"; + const agentId = data.agent_id || data.agentName; + const agentType = data.agent_type || data.agentDisplayName || data.agentName; + const lastMsg = + typeof data.last_assistant_message === "string" + ? data.last_assistant_message.slice(0, 4000) + : ""; + + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "subagent_stop", + sessionId, + project: resolveProject(data.cwd as string | undefined), + cwd: (data.cwd as string | undefined) || process.cwd(), + timestamp: new Date().toISOString(), + data: { + agent_id: agentId, + agent_type: agentType, + last_message: lastMsg, + }, + }), + signal: AbortSignal.timeout(2000), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} + +main(); diff --git a/src/hooks/task-completed.ts b/src/hooks/task-completed.ts new file mode 100644 index 0000000..dbdf815 --- /dev/null +++ b/src/hooks/task-completed.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import { resolveProject } from "./_project.js"; + +function isSdkChildContext(payload: unknown): boolean { + if (process.env["AGENTMEMORY_SDK_CHILD"] === "1") return true; + if (!payload || typeof payload !== "object") return false; + return (payload as { entrypoint?: unknown }).entrypoint === "sdk-ts"; +} + +const REST_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +function authHeaders(): Record<string, string> { + const h: Record<string, string> = { "Content-Type": "application/json" }; + if (SECRET) h["Authorization"] = `Bearer ${SECRET}`; + return h; +} + +async function main() { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + + let data: Record<string, unknown>; + try { + data = JSON.parse(input); + } catch { + return; + } + + if (isSdkChildContext(data)) return; + + const sessionId = (data.session_id as string) || "unknown"; + + fetch(`${REST_URL}/agentmemory/observe`, { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "task_completed", + sessionId, + project: resolveProject(data.cwd as string | undefined), + cwd: (data.cwd as string | undefined) || process.cwd(), + timestamp: new Date().toISOString(), + data: { + task_id: data.task_id, + task_subject: data.task_subject, + task_description: typeof data.task_description === "string" + ? data.task_description.slice(0, 2000) + : "", + teammate_name: data.teammate_name, + team_name: data.team_name, + }, + }), + signal: AbortSignal.timeout(2000), + }).catch(() => {}); + setTimeout(() => process.exit(0), 500).unref(); +} + +main(); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1e623ea --- /dev/null +++ b/src/index.ts @@ -0,0 +1,612 @@ +import { registerWorker } from "iii-sdk"; +import { + loadConfig, + getEnvVar, + loadEmbeddingConfig, + loadFallbackConfig, + loadClaudeBridgeConfig, + loadTeamConfig, + loadSnapshotConfig, + isGraphExtractionEnabled, + isAutoCompressEnabled, + isConsolidationEnabled, + isContextInjectionEnabled, + isDropStaleIndexEnabled, +} from "./config.js"; +import { + createProvider, + createFallbackProvider, + createEmbeddingProvider, + createImageEmbeddingProvider, +} from "./providers/index.js"; +import { StateKV } from "./state/kv.js"; +import { KV } from "./state/schema.js"; +import { VectorIndex } from "./state/vector-index.js"; +import { HybridSearch } from "./state/hybrid-search.js"; +import { IndexPersistence } from "./state/index-persistence.js"; +import { registerPrivacyFunction } from "./functions/privacy.js"; +import { registerObserveFunction } from "./functions/observe.js"; +import { registerImageQuotaCleanup } from "./functions/image-quota-cleanup.js"; +import { registerVisionSearchFunctions } from "./functions/vision-search.js"; +import { registerSlotsFunctions, isSlotsEnabled, isReflectEnabled } from "./functions/slots.js"; +import { registerDiskSizeManager } from "./functions/disk-size-manager.js"; +import { registerCompressFunction } from "./functions/compress.js"; +import { + registerSearchFunction, + rebuildIndex, + getSearchIndex, + setVectorIndex, + setEmbeddingProvider, + setIndexPersistence, +} from "./functions/search.js"; +import { registerContextFunction } from "./functions/context.js"; +import { registerSummarizeFunction } from "./functions/summarize.js"; +import { registerMigrateFunction } from "./functions/migrate.js"; +import { registerFileIndexFunction } from "./functions/file-index.js"; +import { registerConsolidateFunction } from "./functions/consolidate.js"; +import { registerPatternsFunction } from "./functions/patterns.js"; +import { registerRememberFunction } from "./functions/remember.js"; +import { registerEvictFunction } from "./functions/evict.js"; +import { registerRelationsFunction } from "./functions/relations.js"; +import { registerTimelineFunction } from "./functions/timeline.js"; +import { registerSmartSearchFunction } from "./functions/smart-search.js"; +import { registerRecentSearchesSweepFunction } from "./functions/recent-searches-sweep.js"; +import { registerProfileFunction } from "./functions/profile.js"; +import { registerAutoForgetFunction } from "./functions/auto-forget.js"; +import { registerExportImportFunction } from "./functions/export-import.js"; +import { registerEnrichFunction } from "./functions/enrich.js"; +import { registerClaudeBridgeFunction } from "./functions/claude-bridge.js"; +import { registerGraphFunction } from "./functions/graph.js"; +import { registerConsolidationPipelineFunction } from "./functions/consolidation-pipeline.js"; +import { registerTeamFunction } from "./functions/team.js"; +import { registerGovernanceFunction } from "./functions/governance.js"; +import { registerSnapshotFunction } from "./functions/snapshot.js"; +import { registerActionsFunction } from "./functions/actions.js"; +import { registerFrontierFunction } from "./functions/frontier.js"; +import { registerLeasesFunction } from "./functions/leases.js"; +import { registerRoutinesFunction } from "./functions/routines.js"; +import { registerSignalsFunction } from "./functions/signals.js"; +import { registerCheckpointsFunction } from "./functions/checkpoints.js"; +import { registerFlowCompressFunction } from "./functions/flow-compress.js"; +import { registerMeshFunction } from "./functions/mesh.js"; +import { registerBranchAwareFunction } from "./functions/branch-aware.js"; +import { registerSentinelsFunction } from "./functions/sentinels.js"; +import { registerSketchesFunction } from "./functions/sketches.js"; +import { registerCrystallizeFunction } from "./functions/crystallize.js"; +import { registerDiagnosticsFunction } from "./functions/diagnostics.js"; +import { registerFacetsFunction } from "./functions/facets.js"; +import { registerVerifyFunction } from "./functions/verify.js"; +import { registerCascadeFunction } from "./functions/cascade.js"; +import { registerLessonsFunctions } from "./functions/lessons.js"; +import { registerObsidianExportFunction } from "./functions/obsidian-export.js"; +import { registerReflectFunctions } from "./functions/reflect.js"; +import { registerWorkingMemoryFunctions } from "./functions/working-memory.js"; +import { registerSkillExtractFunctions } from "./functions/skill-extract.js"; +import { registerSlidingWindowFunction } from "./functions/sliding-window.js"; +import { registerQueryExpansionFunction } from "./functions/query-expansion.js"; +import { registerTemporalGraphFunctions } from "./functions/temporal-graph.js"; +import { registerRetentionFunctions } from "./functions/retention.js"; +import { registerCompressFileFunction } from "./functions/compress-file.js"; +import { registerReplayFunctions } from "./functions/replay.js"; +import { registerApiTriggers } from "./triggers/api.js"; +import { registerEventTriggers } from "./triggers/events.js"; +import { registerMcpEndpoints } from "./mcp/server.js"; +import { getAllTools } from "./mcp/tools-registry.js"; +import { startViewerServer } from "./viewer/server.js"; +import { MetricsStore } from "./eval/metrics-store.js"; +import { DedupMap } from "./functions/dedup.js"; +import { registerHealthMonitor } from "./health/monitor.js"; +import { initMetrics, OTEL_CONFIG } from "./telemetry/setup.js"; +import { VERSION } from "./version.js"; +import { bootLog } from "./logger.js"; +import { mkdirSync, writeFileSync, unlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +// #640 + #474: the worker process (this file) is spawned by iii-exec +// inside the engine. When `agentmemory stop` kills only the engine pid, +// this worker can survive (detached spawn, signal not propagated, or a +// wrapper script keeps it running) and reconnects to the next engine as +// a duplicate worker. Write the worker pid alongside iii.pid so +// `agentmemory stop` can reap us too. +function workerPidfilePath(): string { + return join(homedir(), ".agentmemory", "worker.pid"); +} +function writeWorkerPidfile(): void { + try { + const p = workerPidfilePath(); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, `${process.pid}\n`, { encoding: "utf-8" }); + } catch { + // best-effort; stop still has the engine pidfile + port scan fallback + } +} +function clearWorkerPidfile(): void { + try { + unlinkSync(workerPidfilePath()); + } catch {} +} + +function hasGetMeter( + sdk: unknown, +): sdk is { getMeter: (name: string) => unknown } { + return ( + typeof sdk === "object" && + sdk !== null && + "getMeter" in sdk && + typeof (sdk as { getMeter?: unknown }).getMeter === "function" + ); +} + +// Top-level safety net for iii-engine invocation timeouts (issue #204). +// Under sustained write load (e.g. Claude Code hooks across many +// projects) `state::set` can occasionally exceed the SDK's 30s timeout. +// We don't want one such timeout to terminate the long-lived memory +// service — the rejection is surfaced to the relevant call site via +// .catch() where it matters; everything else is logged-and-continued. +// Throttle logs to avoid spamming on bursts. +let lastUnhandledLogAt = 0; +process.on("unhandledRejection", (reason) => { + const now = Date.now(); + if (now - lastUnhandledLogAt < 60_000) return; + lastUnhandledLogAt = now; + const r = reason as { code?: string; function_id?: string; message?: string }; + console.warn( + `[agentmemory] unhandledRejection (suppressed):`, + r?.code ? `${r.code} ${r.function_id ?? ""} ${r.message ?? ""}`.trim() : reason, + ); +}); + +async function main() { + const config = loadConfig(); + const embeddingConfig = loadEmbeddingConfig(); + const fallbackConfig = loadFallbackConfig(); + + const provider = + fallbackConfig.providers.length > 0 + ? createFallbackProvider(config.provider, fallbackConfig) + : createProvider(config.provider); + + const embeddingProvider = createEmbeddingProvider(); + const imageEmbeddingProvider = createImageEmbeddingProvider(); + + bootLog(`Starting worker v${VERSION}...`); + bootLog(`Engine: ${config.engineUrl}`); + bootLog( + `Provider: ${config.provider.provider} (${config.provider.model})`, + ); + if (embeddingProvider) { + bootLog( + `Embedding provider: ${embeddingProvider.name} (${embeddingProvider.dimensions} dims)`, + ); + } else { + bootLog(`Embedding provider: none (BM25-only mode)`); + } + if (imageEmbeddingProvider) { + bootLog( + `Image embedding provider: ${imageEmbeddingProvider.name} (${imageEmbeddingProvider.dimensions} dims) — vision-search active`, + ); + } + bootLog( + `REST API: http://localhost:${config.restPort}/agentmemory/*`, + ); + bootLog(`Streams: ws://localhost:${config.streamsPort}`); + + const sdk = registerWorker(config.engineUrl, { + workerName: "agentmemory", + invocationTimeoutMs: 180000, + otel: { + serviceName: OTEL_CONFIG.serviceName, + serviceVersion: OTEL_CONFIG.serviceVersion, + metricsExportIntervalMs: OTEL_CONFIG.metricsExportIntervalMs, + }, + // Explicit worker telemetry metadata. iii-sdk falls back to + // auto-detection (cwd / package.json name / hostname) when this + // is omitted, which produces inconsistent values per host — + // `agentmemory`, `node`, `npm`, occasionally the user's home + // directory basename. Pinning the value here gives every install + // the same stable project identifier for downstream attribution + // and grouping in the engine's metrics + traces output. + telemetry: { + project_name: "agentmemory", + language: "node", + framework: "iii-sdk", + }, + }); + + writeWorkerPidfile(); + + const kv = new StateKV(sdk); + const secret = getEnvVar("AGENTMEMORY_SECRET"); + const metricsStore = new MetricsStore(kv); + const dedupMap = new DedupMap(); + + const vectorIndex = embeddingProvider ? new VectorIndex() : null; + + setVectorIndex(vectorIndex); + setEmbeddingProvider(embeddingProvider); + + const meterAccessor = hasGetMeter(sdk) + ? (sdk.getMeter.bind(sdk) as (name: string) => unknown) + : undefined; + + initMetrics(meterAccessor as ((name: string) => import("@opentelemetry/api").Meter) | undefined); + + registerPrivacyFunction(sdk); + registerObserveFunction(sdk, kv, dedupMap, config.maxObservationsPerSession); + registerImageQuotaCleanup(sdk, kv); + registerVisionSearchFunctions(sdk, kv, imageEmbeddingProvider); + if (isSlotsEnabled()) { + registerSlotsFunctions(sdk, kv); + } + registerDiskSizeManager(sdk, kv); + registerCompressFunction(sdk, kv, provider, metricsStore); + registerSearchFunction(sdk, kv); + registerContextFunction(sdk, kv, config.tokenBudget); + registerSummarizeFunction(sdk, kv, provider, metricsStore); + registerMigrateFunction(sdk, kv); + registerFileIndexFunction(sdk, kv); + registerConsolidateFunction(sdk, kv, provider); + registerPatternsFunction(sdk, kv); + registerRememberFunction(sdk, kv); + registerEvictFunction(sdk, kv); + + registerRelationsFunction(sdk, kv); + registerTimelineFunction(sdk, kv); + registerProfileFunction(sdk, kv); + registerAutoForgetFunction(sdk, kv); + registerExportImportFunction(sdk, kv); + registerEnrichFunction(sdk, kv); + + const claudeBridgeConfig = loadClaudeBridgeConfig(); + if (claudeBridgeConfig.enabled) { + registerClaudeBridgeFunction(sdk, kv, claudeBridgeConfig); + bootLog( + `Claude bridge: syncing to ${claudeBridgeConfig.memoryFilePath}`, + ); + } + + if (isGraphExtractionEnabled()) { + registerGraphFunction(sdk, kv, provider); + bootLog(`Knowledge graph: extraction enabled`); + } + + registerConsolidationPipelineFunction(sdk, kv, provider); + bootLog(`Consolidation pipeline: registered (CONSOLIDATION_ENABLED=${isConsolidationEnabled() ? "true" : "false"})`); + + if (isAutoCompressEnabled()) { + bootLog( + `WARNING: AGENTMEMORY_AUTO_COMPRESS=true — every PostToolUse observation will be sent to your LLM provider for compression. This spends API tokens proportional to your session tool-use frequency. Set AGENTMEMORY_AUTO_COMPRESS=false to disable.`, + ); + } else { + bootLog( + `Auto-compress: OFF (default) — observations indexed via zero-LLM synthetic compression. Set AGENTMEMORY_AUTO_COMPRESS=true to opt-in to LLM-powered summaries (uses your API key).`, + ); + } + + if (isContextInjectionEnabled()) { + bootLog( + `WARNING: AGENTMEMORY_INJECT_CONTEXT=true — the PreToolUse and SessionStart hooks will inject up to ~4000 chars of memory context into every tool turn. On Claude Pro this burns session tokens proportional to your tool-call frequency. Set AGENTMEMORY_INJECT_CONTEXT=false to disable.`, + ); + } else { + bootLog( + `Context injection: OFF (default) — hooks capture observations but do not inject context into Claude Code's conversation. Set AGENTMEMORY_INJECT_CONTEXT=true to opt-in (warning: expect your Claude Pro allocation to drain faster).`, + ); + } + + const teamConfig = loadTeamConfig(); + if (teamConfig) { + registerTeamFunction(sdk, kv, teamConfig); + bootLog( + `Team memory: ${teamConfig.teamId} (${teamConfig.mode})`, + ); + } + + registerGovernanceFunction(sdk, kv); + + registerActionsFunction(sdk, kv); + registerFrontierFunction(sdk, kv); + registerLeasesFunction(sdk, kv); + registerRoutinesFunction(sdk, kv); + registerSignalsFunction(sdk, kv); + registerCheckpointsFunction(sdk, kv); + registerMeshFunction(sdk, kv, secret); + registerBranchAwareFunction(sdk, kv); + registerFlowCompressFunction(sdk, kv, provider); + registerSentinelsFunction(sdk, kv); + registerSketchesFunction(sdk, kv); + registerCrystallizeFunction(sdk, kv, provider); + registerDiagnosticsFunction(sdk, kv); + registerFacetsFunction(sdk, kv); + registerVerifyFunction(sdk, kv); + registerLessonsFunctions(sdk, kv); + registerObsidianExportFunction(sdk, kv); + registerReflectFunctions(sdk, kv, provider); + registerWorkingMemoryFunctions(sdk, kv, config.tokenBudget); + registerSkillExtractFunctions(sdk, kv, provider); + registerCascadeFunction(sdk, kv); + + registerSlidingWindowFunction(sdk, kv, provider); + registerQueryExpansionFunction(sdk, provider); + registerTemporalGraphFunctions(sdk, kv, provider); + registerRetentionFunctions(sdk, kv); + registerCompressFileFunction(sdk, kv, provider); + registerReplayFunctions(sdk, kv); + bootLog( + `v0.6 advanced retrieval: sliding-window, query-expansion, temporal-graph, retention-scoring`, + ); + bootLog( + `Orchestration layer: actions, frontier, leases, routines, signals, checkpoints, flow-compress, mesh, branch-aware, sentinels, sketches, crystallize, diagnostics, facets`, + ); + if (isSlotsEnabled()) { + bootLog( + `Slots: enabled (pinned editable memory). Reflect on Stop hook: ${isReflectEnabled() ? "on" : "off"}`, + ); + } + + const snapshotConfig = loadSnapshotConfig(); + if (snapshotConfig.enabled) { + registerSnapshotFunction(sdk, kv, snapshotConfig.dir); + bootLog( + `Git snapshots: ${snapshotConfig.dir} (every ${snapshotConfig.interval}s)`, + ); + } + + const bm25Index = getSearchIndex(); + const graphWeight = parseFloat(getEnvVar("AGENTMEMORY_GRAPH_WEIGHT") || "0.3"); + const hybridSearch = new HybridSearch( + bm25Index, + vectorIndex, + embeddingProvider, + kv, + embeddingConfig.bm25Weight, + embeddingConfig.vectorWeight, + graphWeight, + ); + + registerSmartSearchFunction(sdk, kv, (query, limit) => + hybridSearch.search(query, limit), + ); + registerRecentSearchesSweepFunction(sdk, kv); + + registerApiTriggers(sdk, kv, secret, metricsStore, provider); + registerEventTriggers(sdk, kv); + registerMcpEndpoints(sdk, kv, secret); + + const healthMonitor = registerHealthMonitor(sdk, kv); + + const indexPersistence = new IndexPersistence(kv, bm25Index, vectorIndex); + // Wire the persistence hook so delete paths can flush BM25/vector + // index mutations to disk. Without this, an in-memory remove can be + // lost across a hard process exit and the persisted snapshot + // restores the deleted entry at next boot. + setIndexPersistence(indexPersistence); + + const loaded = await indexPersistence.load().catch((err) => { + console.warn(`[agentmemory] Failed to load persisted index:`, err); + return null; + }); + if (loaded?.bm25 && loaded.bm25.size > 0) { + bm25Index.restoreFrom(loaded.bm25); + bootLog( + `Loaded persisted BM25 index (${bm25Index.size} docs)`, + ); + } + if (loaded?.vector && vectorIndex && loaded.vector.size > 0) { + // Persisted vectors carry whatever dimension the provider had when + // they were written. If the active provider declares a different + // dimension — or if the on-disk index contains a mix of dimensions + // (legacy indexes written before the live-API guard in this PR) — + // restoring would silently corrupt search: cosineSimilarity returns + // 0 on cross-dim pairs, so affected observations stop matching + // anything and recall degrades without an error. Walk every stored + // vector instead of trusting the first; refuse to load if anything + // is off. + const activeDim = embeddingProvider?.dimensions ?? 0; + const { mismatches, seenDimensions } = + activeDim > 0 + ? loaded.vector.validateDimensions(activeDim) + : { mismatches: [], seenDimensions: new Set<number>() }; + + if (mismatches.length > 0) { + const sample = mismatches + .slice(0, 5) + .map((m) => `${m.obsId} (dim=${m.dim})`) + .join(", "); + const distinct = Array.from(seenDimensions).sort((a, b) => a - b).join(", "); + const dropStale = isDropStaleIndexEnabled(); + if (dropStale) { + console.warn( + `[agentmemory] Persisted vector index has ${mismatches.length} of ` + + `${loaded.vector.size} vectors with the wrong dimension. Active ` + + `provider (${embeddingProvider?.name}) declares ${activeDim}; ` + + `dimensions seen on disk: ${distinct}. ` + + `AGENTMEMORY_DROP_STALE_INDEX=true is set — discarding the persisted ` + + `vectors. Live observations will rebuild the index over time.`, + ); + } else { + throw new Error( + `[agentmemory] Refusing to start: persisted vector index has ` + + `${mismatches.length} of ${loaded.vector.size} vectors with the ` + + `wrong dimension. Active provider (${embeddingProvider?.name}) ` + + `declares ${activeDim}; dimensions seen on disk: ${distinct}. ` + + `First mismatched obsIds: ${sample}. Loading would silently corrupt ` + + `search (cross-dimension cosine returns 0). Choose one:\n` + + ` - Re-embed the existing index against the new provider, then start.\n` + + ` - Set AGENTMEMORY_DROP_STALE_INDEX=true to discard the persisted ` + + `vectors and rebuild from live observations.\n` + + ` - Switch the embedding provider back to the one that wrote the index.`, + ); + } + } else { + vectorIndex.restoreFrom(loaded.vector); + bootLog( + `Loaded persisted vector index (${vectorIndex.size} vectors)`, + ); + } + } + + const needsRebuild = bm25Index.size === 0; + + if (needsRebuild) { + // Fire-and-forget. rebuildIndex iterates every observation across + // every session and AWAITS an embedding-provider call per record. + // On a large corpus + rate-limited embedding endpoint that can + // take HOURS; awaiting it here blocks every subsequent boot step + // (including startViewerServer below, leaving the viewer port + // unbound for the duration). The index lazily fills in over time + // and search degrades gracefully — partial coverage > no viewer + // for hours. Errors still surface via the inner .catch. + void rebuildIndex(kv) + .then((indexCount) => { + if (indexCount > 0) { + bootLog(`Search index rebuilt: ${indexCount} entries`); + indexPersistence.scheduleSave(); + } + }) + .catch((err) => { + console.warn(`[agentmemory] Failed to rebuild search index:`, err); + }); + } else { + // Backfill memories into BM25 for users upgrading from <0.9.5: prior + // versions of mem::remember never indexed memories, so the persisted + // BM25 covers observations only and `memory_smart_search` returns + // empty for everything saved via memory_save (#257). Walk KV.memories + // and add the ones missing from the restored index. Idempotent on + // re-runs because SearchIndex.has() short-circuits already-indexed + // ids. + try { + const memories = await kv.list<import("./types.js").Memory>(KV.memories); + let backfilled = 0; + for (const memory of memories) { + if (memory.isLatest === false) continue; + if (!memory.title || !memory.content) continue; + if (bm25Index.has(memory.id)) continue; + bm25Index.add({ + id: memory.id, + sessionId: memory.sessionIds?.[0] ?? "memory", + timestamp: memory.createdAt, + type: "decision", + title: memory.title, + facts: [memory.content], + narrative: memory.content, + concepts: memory.concepts, + files: memory.files, + importance: memory.strength, + }); + backfilled++; + } + if (backfilled > 0) { + bootLog( + `Backfilled ${backfilled} memories into BM25 (legacy index gap)`, + ); + indexPersistence.scheduleSave(); + } + } catch (err) { + console.warn( + `[agentmemory] Failed to backfill memories into BM25:`, + err, + ); + } + } + + // Ready / Endpoints lines are emitted via `bootLog` so they're + // buffered in quiet mode and printed verbatim under --verbose. The + // CLI surfaces a compact summary when it sees the worker reach + // ready state. + bootLog( + `Ready. ${embeddingProvider ? "Triple-stream (BM25+Vector+Graph)" : "BM25+Graph"} search active.`, + ); + bootLog( + `REST API: 128 endpoints at http://localhost:${config.restPort}/agentmemory/*`, + ); + bootLog( + `MCP surface (opt-in via \`npx @agentmemory/mcp\`): ${getAllTools().length} tools · 6 resources · 3 prompts`, + ); + + const viewerPort = config.restPort + 2; + const viewerServer = startViewerServer( + viewerPort, + kv, + sdk, + secret, + config.restPort, + ); + + const autoForgetIntervalMs = parseInt(process.env.AUTO_FORGET_INTERVAL_MS || "3600000", 10); + const consolidationIntervalMs = parseInt(process.env.CONSOLIDATION_INTERVAL_MS || "7200000", 10); + + if (process.env.AUTO_FORGET_ENABLED !== "false") { + const autoForgetTimer = setInterval(async () => { + try { + await sdk.trigger({ function_id: "mem::auto-forget", payload: { dryRun: false } }); + } catch {} + }, autoForgetIntervalMs); + autoForgetTimer.unref(); + bootLog(`Auto-forget: enabled (every ${autoForgetIntervalMs / 60000}m)`); + } + + if (process.env.LESSON_DECAY_ENABLED !== "false") { + const lessonDecayTimer = setInterval(async () => { + try { + await sdk.trigger({ function_id: "mem::lesson-decay-sweep", payload: {} }); + } catch {} + }, 86400000); + lessonDecayTimer.unref(); + bootLog(`Lesson decay sweep: enabled (every 24h)`); + } + + if (process.env.INSIGHT_DECAY_ENABLED !== "false") { + const insightDecayTimer = setInterval(async () => { + try { + await sdk.trigger({ function_id: "mem::insight-decay-sweep", payload: {} }); + } catch {} + }, 86400000); + insightDecayTimer.unref(); + } + + // #771: hourly TTL sweep for the followup-rate diagnostic. The + // recent-searches scope only needs the last entry per session; + // sweeping anything older than the retention window keeps the scope + // from growing unbounded across long-lived deployments. + const recentSearchesSweepTimer = setInterval(async () => { + try { + await sdk.trigger({ + function_id: "mem::diagnostic::recent-searches-sweep", + payload: {}, + }); + } catch {} + }, 60 * 60 * 1000); + recentSearchesSweepTimer.unref(); + + if (isConsolidationEnabled()) { + const consolidationTimer = setInterval(async () => { + try { + await sdk.trigger({ function_id: "mem::consolidate-pipeline", payload: {} }); + } catch {} + }, consolidationIntervalMs); + consolidationTimer.unref(); + bootLog(`Auto-consolidation: enabled (every ${consolidationIntervalMs / 60000}m)`); + } + + const shutdown = async () => { + console.log(`\n[agentmemory] Shutting down...`); + healthMonitor.stop(); + dedupMap.stop(); + indexPersistence.stop(); + await new Promise<void>((resolve) => viewerServer.close(() => resolve())); + await indexPersistence.save().catch((err) => { + console.warn(`[agentmemory] Failed to save index on shutdown:`, err); + }); + await sdk.shutdown(); + clearWorkerPidfile(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((err) => { + console.error(`[agentmemory] Fatal:`, err); + process.exit(1); +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..8d4ad37 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,110 @@ +// Thin logging shim for agentmemory. +// +// iii-sdk v0.11 dropped `getContext()`, which had been the source of a +// contextual logger in every function handler (`getContext().logger`). +// Migrating directly to the v0.11 OTEL-based `getLogger()` would force +// every call site to care about the OTEL Logger API shape (`emit(...)` +// with severity numbers and attributes maps). Instead, this module +// exposes a single `logger` singleton with the same `.info/.warn/.error` +// signature the old code used, so the mechanical replacement across +// 30+ function files is: drop the `getContext` import, drop the +// `const ctx = getContext();` line, and rename `ctx.logger.*` to +// `logger.*`. Nothing else changes. +// +// Output goes to stderr as `[agentmemory] <level> <msg> <json-fields>`. +// The iii-engine's `iii-exec` worker runs the agentmemory binary as a +// child process and forwards stderr into `docker logs +// agentmemory-iii-engine-1`, so these lines end up next to the engine's +// own output without needing any OTEL wiring. If we later want +// structured OTEL logs, this file is the only thing that changes. +// +// See rohitg00/agentmemory#143 follow-up — the #116 migration updated +// test mocks but left the real `getContext()` imports in place, which +// passed `npm test` (tests mock iii-sdk) and `npm run build` (tsdown +// doesn't type-check) but crashed `node dist/index.mjs` on first +// import. + +type Fields = Record<string, unknown> | undefined; + +function fmt(level: string, msg: string, fields: Fields): string { + if (!fields || Object.keys(fields).length === 0) { + return `[agentmemory] ${level} ${msg}`; + } + try { + return `[agentmemory] ${level} ${msg} ${JSON.stringify(fields)}`; + } catch { + // Fields contained a circular reference or a BigInt — fall back + // to the plain message so a log line never throws. + return `[agentmemory] ${level} ${msg}`; + } +} + +function emit(level: string, msg: string, fields: Fields): void { + try { + process.stderr.write(fmt(level, msg, fields) + "\n"); + } catch { + // stderr is unavailable in some weird test/worker contexts — swallow + // so no log line can ever crash a handler. + } +} + +export const logger = { + info(msg: string, fields?: Fields): void { + emit("info", msg, fields); + }, + warn(msg: string, fields?: Fields): void { + emit("warn", msg, fields); + }, + error(msg: string, fields?: Fields): void { + emit("error", msg, fields); + }, +}; + +// ---------- boot log ---------- +// +// `bootLog` is for the one-shot status lines that every register-* +// function used to dump via `console.log` during engine startup. On a +// fresh install that's ~25 lines of `[agentmemory] X enabled` noise +// before the user can see a prompt. In quiet mode (default), each +// line is captured into a buffer and discarded; the CLI surfaces a +// single compressed summary instead. In verbose mode (set by +// `--verbose` or `AGENTMEMORY_VERBOSE=1`) the lines pass straight +// through to stderr exactly like the old console.log calls. + +let bootVerbose = + process.env["AGENTMEMORY_VERBOSE"] === "1" || + process.env["AGENTMEMORY_VERBOSE"] === "true"; + +const bootBuffer: string[] = []; + +export function setBootVerbose(enabled: boolean): void { + bootVerbose = enabled; +} + +export function isBootVerbose(): boolean { + return bootVerbose; +} + +export function bootLog(msg: string): void { + if (bootVerbose) { + try { + process.stderr.write(`[agentmemory] ${msg}\n`); + } catch { + // stderr unavailable — drop. + } + return; + } + if (bootBuffer.length < 500) bootBuffer.push(msg); +} + +export function bootWarn(msg: string): void { + // Warnings always surface; they're rare and the user needs to see + // them even when the rest of the boot log is suppressed. + try { + process.stderr.write(`[agentmemory] warn ${msg}\n`); + } catch {} +} + +export function getBootBuffer(): readonly string[] { + return bootBuffer; +} diff --git a/src/mcp/in-memory-kv.ts b/src/mcp/in-memory-kv.ts new file mode 100644 index 0000000..0dacebf --- /dev/null +++ b/src/mcp/in-memory-kv.ts @@ -0,0 +1,61 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export class InMemoryKV { + private store = new Map<string, Map<string, unknown>>(); + + constructor(private persistPath?: string) { + if (persistPath && existsSync(persistPath)) { + try { + const data = JSON.parse(readFileSync(persistPath, "utf-8")); + for (const [scope, entries] of Object.entries(data)) { + const map = new Map<string, unknown>(); + for (const [key, value] of Object.entries( + entries as Record<string, unknown>, + )) { + map.set(key, value); + } + this.store.set(scope, map); + } + } catch { + // start fresh + } + } + } + + async get<T = unknown>(scope: string, key: string): Promise<T | null> { + return (this.store.get(scope)?.get(key) as T) ?? null; + } + + async set<T = unknown>(scope: string, key: string, data: T): Promise<T> { + if (!this.store.has(scope)) this.store.set(scope, new Map()); + this.store.get(scope)!.set(key, data); + return data; + } + + async delete(scope: string, key: string): Promise<void> { + this.store.get(scope)?.delete(key); + } + + async list<T = unknown>(scope: string): Promise<T[]> { + const entries = this.store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + } + + persist(): void { + if (!this.persistPath) return; + try { + const dir = dirname(this.persistPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const data: Record<string, Record<string, unknown>> = {}; + for (const [scope, entries] of this.store) { + data[scope] = Object.fromEntries(entries); + } + writeFileSync(this.persistPath, JSON.stringify(data), "utf-8"); + } catch (err) { + process.stderr.write( + `[@agentmemory/mcp] Persist failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} diff --git a/src/mcp/rest-proxy.ts b/src/mcp/rest-proxy.ts new file mode 100644 index 0000000..1adbb46 --- /dev/null +++ b/src/mcp/rest-proxy.ts @@ -0,0 +1,179 @@ +const DEFAULT_URL = "http://localhost:3111"; +const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 2_000; +const CALL_TIMEOUT_MS = 15_000; +const LOCAL_MODE_TTL_MS = 30_000; + +function probeTimeoutMs(): number { + const raw = process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"]; + if (!raw) return DEFAULT_HEALTH_PROBE_TIMEOUT_MS; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_HEALTH_PROBE_TIMEOUT_MS; +} + +function forceProxy(): boolean { + const raw = process.env["AGENTMEMORY_FORCE_PROXY"]; + return raw === "1" || raw === "true"; +} + +export interface ProxyHandle { + mode: "proxy"; + baseUrl: string; + call: (path: string, init?: RequestInit) => Promise<unknown>; +} + +export interface LocalHandle { + mode: "local"; +} + +export type Handle = ProxyHandle | LocalHandle; + +let cached: Handle | null = null; +let cachedAt = 0; +let probeInFlight: Promise<Handle> | null = null; + +// `${VAR}`-style placeholders ship in plugin/.mcp.json so MCP hosts that +// expand them (Claude Code, Cursor) substitute the user's shell value. +// Hosts that DON'T expand pass the literal string `"${AGENTMEMORY_URL}"` +// through to our subprocess — that string is truthy, defeats the `||` +// fallback, and would have us POST to `${AGENTMEMORY_URL}/agentmemory/...` +// (DNS failure). Strip any literal placeholder we see so the fallback +// engages instead. +export function resolveEnvOrEmpty(name: string): string { + const raw = process.env[name]; + if (!raw) return ""; + if (raw.startsWith("${") && raw.endsWith("}")) return ""; + return raw; +} + +function baseUrl(): string { + return (resolveEnvOrEmpty("AGENTMEMORY_URL") || DEFAULT_URL).replace(/\/+$/, ""); +} + +function authHeader(): Record<string, string> { + const secret = resolveEnvOrEmpty("AGENTMEMORY_SECRET"); + return secret ? { authorization: `Bearer ${secret}` } : {}; +} + +/** + * Probes the agentmemory server's livez endpoint. Returns a Response-shaped + * object whose `ok` flag drives the proxy/local-fallback decision. + * + * Tests can swap this via {@link setLivezProbe} to avoid the real 2s + * AbortController race that destabilises mcp-standalone test runs (#449). + * Production callers should leave it on the default. + */ +export type LivezProbe = ( + url: string, + timeoutMs: number, + headers: Record<string, string>, +) => Promise<{ ok: boolean; status?: number; statusText?: string }>; + +const defaultLivezProbe: LivezProbe = async (url, timeoutMs, headers) => { + const res = await fetch(`${url}/agentmemory/livez`, { + method: "GET", + headers, + signal: AbortSignal.timeout(timeoutMs), + }); + return { ok: res.ok, status: res.status, statusText: res.statusText }; +}; + +let livezProbe: LivezProbe = defaultLivezProbe; + +/** + * Override the livez probe. Intended for tests — production code should rely + * on the default fetch-based probe. Calling without an argument restores the + * default. Pair with {@link resetHandleForTests} so the cached handle is + * dropped before the next call. + */ +export function setLivezProbe(fn?: LivezProbe): void { + livezProbe = fn ?? defaultLivezProbe; +} + +async function probe(url: string): Promise<boolean> { + const timeout = probeTimeoutMs(); + try { + const res = await livezProbe(url, timeout, authHeader()); + if (!res.ok) { + process.stderr.write( + `[@agentmemory/mcp] livez probe ${url}/agentmemory/livez -> ${res.status ?? "?"} ${res.statusText ?? ""}; falling back to local InMemoryKV (set AGENTMEMORY_FORCE_PROXY=1 to skip the probe)\n`, + ); + } + return res.ok; + } catch (err) { + process.stderr.write( + `[@agentmemory/mcp] livez probe ${url}/agentmemory/livez failed in ${timeout}ms: ${err instanceof Error ? err.message : String(err)}; falling back to local InMemoryKV (set AGENTMEMORY_FORCE_PROXY=1 to skip the probe, or raise AGENTMEMORY_PROBE_TIMEOUT_MS)\n`, + ); + return false; + } +} + +export function invalidateHandle(): void { + cached = null; + cachedAt = 0; +} + +export async function resolveHandle(): Promise<Handle> { + const now = Date.now(); + if (cached) { + if (cached.mode === "local" && now - cachedAt >= LOCAL_MODE_TTL_MS) { + cached = null; + cachedAt = 0; + } else { + return cached; + } + } + if (probeInFlight) return probeInFlight; + const url = baseUrl(); + const skipProbe = forceProxy(); + probeInFlight = (async () => { + const up = skipProbe ? true : await probe(url); + if (skipProbe) { + process.stderr.write( + `[@agentmemory/mcp] AGENTMEMORY_FORCE_PROXY set; skipping livez probe and trusting ${url}\n`, + ); + } + if (up) { + const handle: ProxyHandle = { + mode: "proxy", + baseUrl: url, + call: async (path, init) => { + const res = await fetch(`${url}${path}`, { + ...init, + headers: { + "content-type": "application/json", + ...authHeader(), + ...(init?.headers as Record<string, string> | undefined), + }, + signal: AbortSignal.timeout(CALL_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error( + `${init?.method || "GET"} ${path} -> ${res.status} ${res.statusText}`, + ); + } + const text = await res.text(); + return text ? JSON.parse(text) : null; + }, + }; + cached = handle; + cachedAt = Date.now(); + return handle; + } + const local: LocalHandle = { mode: "local" }; + cached = local; + cachedAt = Date.now(); + return local; + })(); + try { + return await probeInFlight; + } finally { + probeInFlight = null; + } +} + +export function resetHandleForTests(): void { + cached = null; + cachedAt = 0; + probeInFlight = null; + livezProbe = defaultLivezProbe; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..dbca07d --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,1773 @@ +import type { ISdk, ApiRequest } from "iii-sdk"; +import type { StateKV } from "../state/kv.js"; +import { KV } from "../state/schema.js"; +import type { + SessionSummary, + Memory, + Session, + GraphNode, + GraphEdge, +} from "../types.js"; +import { getVisibleTools } from "./tools-registry.js"; +import { timingSafeCompare } from "../auth.js"; +import { getAgentId, isAgentScopeIsolated } from "../config.js"; + +type McpResponse = { + status_code: number; + headers?: Record<string, string>; + body: unknown; +}; + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function asNumber(value: unknown, fallback?: number): number | undefined { + const n = Number(value); + if (Number.isFinite(n)) return n; + return fallback; +} + +function parseCsvList(value: unknown): string[] { + if (typeof value === "string") { + return value.split(",").map((v) => v.trim()).filter(Boolean); + } + if (Array.isArray(value)) { + return value + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter(Boolean); + } + return []; +} + +export function registerMcpEndpoints( + sdk: ISdk, + kv: StateKV, + secret?: string, +): void { + function checkAuth( + req: ApiRequest, + sec: string | undefined, + ): McpResponse | null { + if (!sec) return null; + const auth = + req.headers?.["authorization"] || req.headers?.["Authorization"]; + if (typeof auth !== "string" || !timingSafeCompare(auth, `Bearer ${sec}`)) { + return { status_code: 401, body: { error: "unauthorized" } }; + } + return null; + } + + sdk.registerFunction("mcp::tools::list", + async (req: ApiRequest): Promise<McpResponse> => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + return { status_code: 200, body: { tools: getVisibleTools() } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "mcp::tools::list", + config: { api_path: "/agentmemory/mcp/tools", http_method: "GET" }, + }); + + sdk.registerFunction("mcp::tools::call", + async ( + req: ApiRequest<{ name: string; arguments: Record<string, unknown> }>, + ): Promise<McpResponse> => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + + if (!req.body || typeof req.body.name !== "string") { + return { status_code: 400, body: { error: "name is required" } }; + } + + const { name, arguments: args = {} } = req.body; + + try { + switch (name) { + case "memory_recall": { + if (typeof args.query !== "string" || !args.query.trim()) { + return { + status_code: 400, + body: { error: "query is required for memory_recall" }, + }; + } + const format = + typeof args.format === "string" ? args.format.trim().toLowerCase() : "full"; + if (!["full", "compact", "narrative"].includes(format)) { + return { + status_code: 400, + body: { + error: "format must be one of: full, compact, narrative", + }, + }; + } + const tokenBudget = asNumber(args.token_budget); + if ( + args.token_budget !== undefined && + (!Number.isInteger(tokenBudget) || (tokenBudget ?? 0) < 1) + ) { + return { + status_code: 400, + body: { error: "token_budget must be a positive integer" }, + }; + } + // #817: forward agentId so mem::search applies the same + // isolation filter smart-search uses. Default behavior is + // unchanged (no agentId → falls back to env AGENT_ID when + // AGENTMEMORY_AGENT_SCOPE=isolated; "*" wildcard bypasses). + const recallAgentId = + typeof args.agentId === "string" && args.agentId.trim().length > 0 + ? (args.agentId as string).trim() + : undefined; + const result = await sdk.trigger({ function_id: "mem::search", payload: { + query: args.query, + limit: typeof args.limit === "number" ? args.limit : 10, + format, + token_budget: tokenBudget, + agentId: recallAgentId, + } }); + const text = + format === "narrative" && + result && + typeof result === "object" && + "text" in (result as Record<string, unknown>) && + typeof (result as { text?: unknown }).text === "string" + ? (result as { text: string }).text + : JSON.stringify(result, null, 2); + return { + status_code: 200, + body: { + content: [ + { type: "text", text }, + ], + }, + }; + } + + case "memory_compress_file": { + if (typeof args.filePath !== "string" || !args.filePath.trim()) { + return { + status_code: 400, + body: { error: "filePath is required for memory_compress_file" }, + }; + } + const result = await sdk.trigger({ + function_id: "mem::compress-file", + payload: { filePath: args.filePath.trim() }, + }); + return { + status_code: 200, + body: { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }, + }; + } + + case "memory_save": { + if (typeof args.content !== "string" || !args.content.trim()) { + return { + status_code: 400, + body: { error: "content is required for memory_save" }, + }; + } + const type = (args.type as string) || "fact"; + const concepts = + typeof args.concepts === "string" + ? args.concepts.split(",").map((c: string) => c.trim()).filter(Boolean) + : []; + const files = + typeof args.files === "string" + ? args.files.split(",").map((f: string) => f.trim()).filter(Boolean) + : []; + + const project = + typeof args.project === "string" && args.project.trim().length > 0 + ? args.project.trim() + : undefined; + + const result = await sdk.trigger({ function_id: "mem::remember", payload: { + content: args.content, + type, + concepts, + files, + ...(project !== undefined && { project }), + } }); + return { + status_code: 200, + body: { + content: [{ type: "text", text: JSON.stringify(result) }], + }, + }; + } + + case "memory_file_history": { + if (typeof args.files !== "string" || !args.files.trim()) { + return { + status_code: 400, + body: { error: "files is required for memory_file_history" }, + }; + } + const fileList = parseCsvList(args.files); + if (!fileList.length) { + return { + status_code: 400, + body: { error: "files must contain at least one valid path" }, + }; + } + const payload: { sessionId?: string; files: string[] } = { files: fileList }; + const sessionId = asNonEmptyString(args.sessionId); + if (sessionId) payload.sessionId = sessionId; + const result = await sdk.trigger({ + function_id: "mem::file-context", + payload, + }); + return { + status_code: 200, + body: { + content: [ + { + type: "text", + text: + (result as { context: string }).context || + "No history found.", + }, + ], + }, + }; + } + + case "memory_patterns": { + const result = await sdk.trigger({ function_id: "mem::patterns", payload: { + project: args.project as string, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } + + case "memory_sessions": { + const sessions = await kv.list(KV.sessions); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify({ sessions }, null, 2) }, + ], + }, + }; + } + + case "memory_smart_search": { + if (typeof args.query !== "string" || !args.query.trim()) { + return { + status_code: 400, + body: { error: "query is required for memory_smart_search" }, + }; + } + const expandIds = parseCsvList(args.expandIds).slice(0, 20); + const limit = Math.max(1, Math.min(100, asNumber(args.limit, 10) ?? 10)); + const result = await sdk.trigger({ + function_id: "mem::smart-search", + payload: { + query: args.query, + expandIds, + limit, + }, + }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } + + case "memory_vision_search": { + const queryText = typeof args.queryText === "string" ? args.queryText : undefined; + const queryImageRef = typeof args.queryImageRef === "string" ? args.queryImageRef : undefined; + const queryImageBase64 = typeof args.queryImageBase64 === "string" ? args.queryImageBase64 : undefined; + if (!queryText && !queryImageRef && !queryImageBase64) { + return { + status_code: 400, + body: { error: "queryText, queryImageRef, or queryImageBase64 required" }, + }; + } + const topK = Math.max(1, Math.min(50, asNumber(args.topK, 10) ?? 10)); + const sessionId = typeof args.sessionId === "string" ? args.sessionId : undefined; + const result = await sdk.trigger({ + function_id: "mem::vision-search", + payload: { queryText, queryImageRef, queryImageBase64, topK, sessionId }, + }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } + + case "memory_timeline": { + if (typeof args.anchor !== "string" || !args.anchor.trim()) { + return { + status_code: 400, + body: { error: "anchor is required for memory_timeline" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::timeline", payload: { + anchor: args.anchor, + project: (args.project as string) || undefined, + before: (args.before as number) || 5, + after: (args.after as number) || 5, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } + + case "memory_profile": { + if (typeof args.project !== "string" || !args.project.trim()) { + return { + status_code: 400, + body: { error: "project is required for memory_profile" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::profile", payload: { + project: args.project, + refresh: args.refresh === true || args.refresh === "true", + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } + + case "memory_export": { + const result = await sdk.trigger({ function_id: "mem::export", payload: {} }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } + + case "memory_relations": { + if (typeof args.memoryId !== "string" || !args.memoryId.trim()) { + return { + status_code: 400, + body: { error: "memoryId is required for memory_relations" }, + }; + } + const rawMaxHops = Number(args.maxHops); + const rawMinConf = Number(args.minConfidence); + const result = await sdk.trigger({ function_id: "mem::get-related", payload: { + memoryId: args.memoryId, + maxHops: Number.isFinite(rawMaxHops) ? rawMaxHops : 2, + minConfidence: Number.isFinite(rawMinConf) + ? Math.max(0, Math.min(1, rawMinConf)) + : 0, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } + + case "memory_claude_bridge_sync": { + const direction = (args.direction as string) || "write"; + const funcId = + direction === "read" + ? "mem::claude-bridge-read" + : "mem::claude-bridge-sync"; + try { + const result = await sdk.trigger({ + function_id: funcId, + payload: {}, + }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + content: [ + { + type: "text", + text: "Claude bridge not enabled. Set CLAUDE_MEMORY_BRIDGE=true", + }, + ], + }, + }; + } + } + + case "memory_graph_query": { + try { + const payload: { + startNodeId?: string; + nodeType?: string; + maxDepth?: number; + query?: string; + } = {}; + const startNodeId = asNonEmptyString(args.startNodeId); + const nodeType = asNonEmptyString(args.nodeType); + const query = asNonEmptyString(args.query); + const maxDepth = asNumber(args.maxDepth); + if (startNodeId) payload.startNodeId = startNodeId; + if (nodeType) payload.nodeType = nodeType; + if (query) payload.query = query; + if (maxDepth !== undefined) payload.maxDepth = Math.max(1, Math.min(8, maxDepth)); + const result = await sdk.trigger({ + function_id: "mem::graph-query", + payload, + }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + content: [ + { + type: "text", + text: "Knowledge graph not enabled. Set GRAPH_EXTRACTION_ENABLED=true", + }, + ], + }, + }; + } + } + + case "memory_consolidate": { + try { + const result = await sdk.trigger({ function_id: "mem::consolidate-pipeline", payload: { + tier: args.tier as string, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + content: [ + { + type: "text", + text: "Consolidation not enabled. Set CONSOLIDATION_ENABLED=true", + }, + ], + }, + }; + } + } + + case "memory_team_share": { + if ( + typeof args.itemId !== "string" || + typeof args.itemType !== "string" + ) { + return { + status_code: 400, + body: { error: "itemId and itemType are required" }, + }; + } + try { + const result = await sdk.trigger({ function_id: "mem::team-share", payload: { + itemId: args.itemId, + itemType: args.itemType, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + content: [ + { + type: "text", + text: "Team memory not enabled. Set TEAM_ID and USER_ID", + }, + ], + }, + }; + } + } + + case "memory_team_feed": { + try { + const result = await sdk.trigger({ function_id: "mem::team-feed", payload: { + limit: typeof args.limit === "number" ? args.limit : 20, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + content: [ + { + type: "text", + text: "Team memory not enabled. Set TEAM_ID and USER_ID", + }, + ], + }, + }; + } + } + + case "memory_audit": { + try { + const result = await sdk.trigger({ function_id: "mem::audit-query", payload: { + operation: args.operation as string, + limit: typeof args.limit === "number" ? args.limit : 50, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + content: [{ type: "text", text: "Audit query failed" }], + isError: true, + }, + }; + } + } + + case "memory_governance_delete": { + if (typeof args.memoryIds !== "string") { + return { + status_code: 400, + body: { error: "memoryIds is required" }, + }; + } + const ids = (args.memoryIds as string) + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + try { + const result = await sdk.trigger({ function_id: "mem::governance-delete", payload: { + memoryIds: ids, + reason: args.reason as string, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + content: [{ type: "text", text: "Governance delete failed" }], + isError: true, + }, + }; + } + } + + case "memory_snapshot_create": { + try { + const result = await sdk.trigger({ function_id: "mem::snapshot-create", payload: { + message: args.message as string, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(result, null, 2) }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + content: [ + { + type: "text", + text: "Snapshots not enabled. Set SNAPSHOT_ENABLED=true", + }, + ], + }, + }; + } + } + + case "memory_action_create": { + if (typeof args.title !== "string" || !args.title.trim()) { + return { + status_code: 400, + body: { error: "title is required" }, + }; + } + const edges: Array<{ type: string; targetActionId: string }> = []; + if (typeof args.requires === "string" && args.requires.trim()) { + for (const id of args.requires.split(",").map((s: string) => s.trim()).filter(Boolean)) { + edges.push({ type: "requires", targetActionId: id }); + } + } + const tags = typeof args.tags === "string" && args.tags.trim() + ? args.tags.split(",").map((t: string) => t.trim()).filter(Boolean) + : []; + const actionResult = await sdk.trigger({ function_id: "mem::action-create", payload: { + title: args.title, + description: args.description, + priority: args.priority, + project: args.project, + tags, + parentId: args.parentId, + edges: edges.length > 0 ? edges : undefined, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(actionResult, null, 2) }, + ], + }, + }; + } + + case "memory_action_update": { + if (typeof args.actionId !== "string" || !args.actionId.trim()) { + return { + status_code: 400, + body: { error: "actionId is required" }, + }; + } + const updateResult = await sdk.trigger({ function_id: "mem::action-update", payload: { + actionId: args.actionId, + status: args.status, + result: args.result, + priority: args.priority, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(updateResult, null, 2) }, + ], + }, + }; + } + + case "memory_frontier": { + const frontierResult = await sdk.trigger({ function_id: "mem::frontier", payload: { + project: args.project, + agentId: args.agentId, + limit: args.limit, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(frontierResult, null, 2) }, + ], + }, + }; + } + + case "memory_next": { + const nextResult = await sdk.trigger({ function_id: "mem::next", payload: { + project: args.project, + agentId: args.agentId, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(nextResult, null, 2) }, + ], + }, + }; + } + + case "memory_lease": { + if ( + typeof args.actionId !== "string" || + typeof args.agentId !== "string" || + typeof args.operation !== "string" + ) { + return { + status_code: 400, + body: { error: "actionId, agentId, and operation are required" }, + }; + } + const op = args.operation as string; + let leaseResult; + if (op === "acquire") { + leaseResult = await sdk.trigger({ function_id: "mem::lease-acquire", payload: { + actionId: args.actionId, + agentId: args.agentId, + ttlMs: args.ttlMs, + } }); + } else if (op === "release") { + leaseResult = await sdk.trigger({ function_id: "mem::lease-release", payload: { + actionId: args.actionId, + agentId: args.agentId, + result: args.result, + } }); + } else if (op === "renew") { + leaseResult = await sdk.trigger({ function_id: "mem::lease-renew", payload: { + actionId: args.actionId, + agentId: args.agentId, + ttlMs: args.ttlMs, + } }); + } else { + return { + status_code: 400, + body: { error: "operation must be acquire, release, or renew" }, + }; + } + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(leaseResult, null, 2) }, + ], + }, + }; + } + + case "memory_routine_run": { + if (typeof args.routineId !== "string") { + return { + status_code: 400, + body: { error: "routineId is required" }, + }; + } + const runResult = await sdk.trigger({ function_id: "mem::routine-run", payload: { + routineId: args.routineId, + project: args.project, + initiatedBy: args.initiatedBy, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(runResult, null, 2) }, + ], + }, + }; + } + + case "memory_signal_send": { + if ( + typeof args.from !== "string" || + typeof args.content !== "string" + ) { + return { + status_code: 400, + body: { error: "from and content are required" }, + }; + } + const sigResult = await sdk.trigger({ function_id: "mem::signal-send", payload: { + from: args.from, + to: args.to, + content: args.content, + type: args.type, + replyTo: args.replyTo, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(sigResult, null, 2) }, + ], + }, + }; + } + + case "memory_signal_read": { + if (typeof args.agentId !== "string") { + return { + status_code: 400, + body: { error: "agentId is required" }, + }; + } + const readResult = await sdk.trigger({ function_id: "mem::signal-read", payload: { + agentId: args.agentId, + unreadOnly: args.unreadOnly === true || args.unreadOnly === "true", + threadId: args.threadId, + limit: args.limit, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(readResult, null, 2) }, + ], + }, + }; + } + + case "memory_checkpoint": { + const cpOp = args.operation as string; + if (!cpOp) { + return { + status_code: 400, + body: { error: "operation is required" }, + }; + } + let cpResult; + if (cpOp === "create") { + const linkedIds = typeof args.linkedActionIds === "string" && args.linkedActionIds.trim() + ? args.linkedActionIds.split(",").map((s: string) => s.trim()) + : []; + cpResult = await sdk.trigger({ function_id: "mem::checkpoint-create", payload: { + name: args.name, + description: args.description, + type: args.type, + linkedActionIds: linkedIds, + } }); + } else if (cpOp === "resolve") { + if (typeof args.checkpointId !== "string" || !args.checkpointId.trim()) { + return { + status_code: 400, + body: { error: "checkpointId is required for resolve operation" }, + }; + } + cpResult = await sdk.trigger({ function_id: "mem::checkpoint-resolve", payload: { + checkpointId: args.checkpointId, + status: args.status, + } }); + } else if (cpOp === "list") { + cpResult = await sdk.trigger({ function_id: "mem::checkpoint-list", payload: { + status: args.status, + type: args.type, + } }); + } else { + return { + status_code: 400, + body: { error: "operation must be create, resolve, or list" }, + }; + } + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(cpResult, null, 2) }, + ], + }, + }; + } + + case "memory_mesh_sync": { + const meshResult = await sdk.trigger({ function_id: "mem::mesh-sync", payload: { + peerId: args.peerId, + direction: args.direction, + } }); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify(meshResult, null, 2) }, + ], + }, + }; + } + + case "memory_sentinel_create": { + let snlConfig: Record<string, unknown> = {}; + if (typeof args.config === "object" && args.config !== null) { + snlConfig = args.config as Record<string, unknown>; + } else if (typeof args.config === "string" && args.config.trim()) { + try { snlConfig = JSON.parse(args.config); } catch { return { status_code: 400, body: { error: "invalid config JSON" } }; } + } + const snlLinked = parseCsvList(args.linkedActionIds); + const expiresInMs = asNumber(args.expiresInMs); + const name = asNonEmptyString(args.name); + const type = asNonEmptyString(args.type); + const payload: { + name?: string; + type?: string; + config: Record<string, unknown>; + linkedActionIds?: string[]; + expiresInMs?: number; + } = { config: snlConfig }; + if (name) payload.name = name; + if (type) payload.type = type; + if (snlLinked.length) payload.linkedActionIds = snlLinked; + if (expiresInMs !== undefined) payload.expiresInMs = Math.max(0, expiresInMs); + const snlResult = await sdk.trigger({ + function_id: "mem::sentinel-create", + payload, + }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(snlResult, null, 2) }] } }; + } + + case "memory_sentinel_trigger": { + let snlTrigPayload: unknown; + if (args.result !== undefined && args.result !== null) { + if (typeof args.result === "string") { + try { snlTrigPayload = JSON.parse(args.result); } catch { return { status_code: 400, body: { error: "invalid result JSON" } }; } + } else { + snlTrigPayload = args.result; + } + } + const sentinelId = asNonEmptyString(args.sentinelId); + if (!sentinelId) { + return { + status_code: 400, + body: { error: "sentinelId is required for memory_sentinel_trigger" }, + }; + } + const snlTrigResult = await sdk.trigger({ function_id: "mem::sentinel-trigger", payload: { + sentinelId, + result: snlTrigPayload, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(snlTrigResult, null, 2) }] } }; + } + + case "memory_sketch_create": { + const title = asNonEmptyString(args.title); + if (!title) { + return { + status_code: 400, + body: { error: "title is required for memory_sketch_create" }, + }; + } + const sketchPayload = { + title, + description: asNonEmptyString(args.description), + expiresInMs: asNumber(args.expiresInMs), + project: asNonEmptyString(args.project), + }; + const skResult = await sdk.trigger({ + function_id: "mem::sketch-create", + payload: sketchPayload, + }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(skResult, null, 2) }] } }; + } + + case "memory_sketch_promote": { + const sketchId = asNonEmptyString(args.sketchId); + if (!sketchId) { + return { + status_code: 400, + body: { error: "sketchId is required for memory_sketch_promote" }, + }; + } + const skpResult = await sdk.trigger({ function_id: "mem::sketch-promote", payload: { + sketchId, + project: args.project, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(skpResult, null, 2) }] } }; + } + + case "memory_crystallize": { + if (typeof args.actionIds !== "string" || !args.actionIds.trim()) { + return { status_code: 400, body: { error: "actionIds is required" } }; + } + const crysIds = args.actionIds.split(",").map((s: string) => s.trim()).filter(Boolean); + const crysResult = await sdk.trigger({ function_id: "mem::crystallize", payload: { + actionIds: crysIds, + project: args.project, + sessionId: args.sessionId, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(crysResult, null, 2) }] } }; + } + + case "memory_diagnose": { + const diagCats = typeof args.categories === "string" && args.categories.trim() + ? args.categories.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const diagResult = await sdk.trigger({ function_id: "mem::diagnose", payload: { categories: diagCats } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(diagResult, null, 2) }] } }; + } + + case "memory_heal": { + const healCats = typeof args.categories === "string" && args.categories.trim() + ? args.categories.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const healResult = await sdk.trigger({ function_id: "mem::heal", payload: { + categories: healCats, + dryRun: args.dryRun === true || args.dryRun === "true", + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(healResult, null, 2) }] } }; + } + + case "memory_facet_tag": { + const fctResult = await sdk.trigger({ function_id: "mem::facet-tag", payload: { + targetId: args.targetId, + targetType: args.targetType, + dimension: args.dimension, + value: args.value, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(fctResult, null, 2) }] } }; + } + + case "memory_facet_query": { + if (args.matchAll !== undefined && typeof args.matchAll !== "string") { + return { status_code: 400, body: { error: "matchAll must be a string" } }; + } + if (args.matchAny !== undefined && typeof args.matchAny !== "string") { + return { status_code: 400, body: { error: "matchAny must be a string" } }; + } + const fqAll = typeof args.matchAll === "string" && args.matchAll.trim() + ? args.matchAll.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const fqAny = typeof args.matchAny === "string" && args.matchAny.trim() + ? args.matchAny.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const fqResult = await sdk.trigger({ function_id: "mem::facet-query", payload: { + matchAll: fqAll, + matchAny: fqAny, + targetType: args.targetType, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(fqResult, null, 2) }] } }; + } + + case "memory_verify": { + if (!args.id || typeof args.id !== "string") { + return { status_code: 400, body: { error: "id is required" } }; + } + const verifyResult = await sdk.trigger({ function_id: "mem::verify", payload: { id: args.id } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(verifyResult, null, 2) }] } }; + } + + case "memory_lesson_save": { + if (typeof args.content !== "string" || !args.content.trim()) { + return { status_code: 400, body: { error: "content is required" } }; + } + const lessonTags = typeof args.tags === "string" && args.tags.trim() + ? args.tags.split(",").map((t: string) => t.trim()).filter(Boolean) + : []; + const lessonSaveResult = await sdk.trigger({ function_id: "mem::lesson-save", payload: { + content: args.content, + context: args.context || "", + confidence: args.confidence, + project: args.project, + tags: lessonTags, + source: "manual", + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(lessonSaveResult, null, 2) }] } }; + } + + case "memory_lesson_recall": { + if (typeof args.query !== "string" || !args.query.trim()) { + return { status_code: 400, body: { error: "query is required" } }; + } + const lessonRecallResult = await sdk.trigger({ function_id: "mem::lesson-recall", payload: { + query: args.query, + project: args.project, + minConfidence: args.minConfidence, + limit: args.limit, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(lessonRecallResult, null, 2) }] } }; + } + + case "memory_reflect": { + const reflectResult = await sdk.trigger({ function_id: "mem::reflect", payload: { + project: args.project, + maxClusters: args.maxClusters, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(reflectResult, null, 2) }] } }; + } + + case "memory_insight_list": { + const insightListResult = await sdk.trigger({ function_id: "mem::insight-list", payload: { + project: args.project, + minConfidence: args.minConfidence, + limit: args.limit, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(insightListResult, null, 2) }] } }; + } + + case "memory_obsidian_export": { + const exportTypes = typeof args.types === "string" && args.types.trim() + ? args.types.split(",").map((t: string) => t.trim()).filter(Boolean) + : undefined; + const obsidianResult = await sdk.trigger({ function_id: "mem::obsidian-export", payload: { + vaultDir: args.vaultDir, + types: exportTypes, + } }); + return { status_code: 200, body: { content: [{ type: "text", text: JSON.stringify(obsidianResult, null, 2) }] } }; + } + + case "memory_slot_list": { + const result = await sdk.trigger({ function_id: "mem::slot-list", payload: {} }); + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }, + }; + } + + case "memory_slot_get": { + const label = asNonEmptyString(args.label); + if (!label) return { status_code: 400, body: { error: "label required" } }; + const result = await sdk.trigger({ function_id: "mem::slot-get", payload: { label } }); + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }, + }; + } + + case "memory_slot_create": { + const label = asNonEmptyString(args.label); + if (!label) return { status_code: 400, body: { error: "label required" } }; + const payload: Record<string, unknown> = { label }; + if (typeof args.content === "string") payload.content = args.content; + if (typeof args.description === "string") payload.description = args.description; + if (typeof args.sizeLimit === "number") payload.sizeLimit = args.sizeLimit; + // Accept boolean and string-boolean forms; MCP clients bind either + // depending on their JSON schema wrapper. + if (args.pinned === false || args.pinned === "false") payload.pinned = false; + else if (args.pinned === true || args.pinned === "true") payload.pinned = true; + if (args.scope === "global" || args.scope === "project") payload.scope = args.scope; + const result = await sdk.trigger({ function_id: "mem::slot-create", payload }); + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }, + }; + } + + case "memory_slot_append": { + const label = asNonEmptyString(args.label); + const text = typeof args.text === "string" ? args.text : null; + if (!label || !text) return { status_code: 400, body: { error: "label and text required" } }; + const result = await sdk.trigger({ function_id: "mem::slot-append", payload: { label, text } }); + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }, + }; + } + + case "memory_slot_replace": { + const label = asNonEmptyString(args.label); + if (!label || typeof args.content !== "string") { + return { status_code: 400, body: { error: "label and content (string) required" } }; + } + const result = await sdk.trigger({ function_id: "mem::slot-replace", payload: { label, content: args.content } }); + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }, + }; + } + + case "memory_slot_delete": { + const label = asNonEmptyString(args.label); + if (!label) return { status_code: 400, body: { error: "label required" } }; + const result = await sdk.trigger({ function_id: "mem::slot-delete", payload: { label } }); + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }, + }; + } + + case "memory_commit_lookup": { + const sha = asNonEmptyString(args.sha); + if (!sha) return { status_code: 400, body: { error: "sha required" } }; + const link = await kv.get(KV.commits, sha); + if (!link) { + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify({ commit: null, sessions: [] }, null, 2) }] }, + }; + } + const linkRecord = link as { sessionIds?: string[] }; + const fetched = await Promise.all( + (linkRecord.sessionIds ?? []).map((sid) => kv.get(KV.sessions, sid)), + ); + const sessions = fetched.filter((s) => s !== null); + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify({ commit: link, sessions }, null, 2) }] }, + }; + } + + case "memory_commits": { + const branch = typeof args.branch === "string" ? args.branch : undefined; + const repo = typeof args.repo === "string" ? args.repo : undefined; + const limit = Math.max(1, Math.min(500, asNumber(args.limit, 100) ?? 100)); + const all = await kv.list(KV.commits); + const filtered = (all as Array<{ branch?: string; repo?: string; linkedAt?: string }>) + .filter((c) => !branch || c.branch === branch) + .filter((c) => !repo || c.repo === repo) + .sort((a, b) => ((a.linkedAt ?? "") < (b.linkedAt ?? "") ? 1 : -1)) + .slice(0, limit); + return { + status_code: 200, + body: { content: [{ type: "text", text: JSON.stringify({ commits: filtered }, null, 2) }] }, + }; + } + + default: + return { + status_code: 400, + body: { error: `Unknown tool: ${name}` }, + }; + } + } catch (err) { + return { + status_code: 500, + body: { + error: "Internal error", + }, + }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "mcp::tools::call", + config: { api_path: "/agentmemory/mcp/call", http_method: "POST" }, + }); + + const MCP_RESOURCES = [ + { + uri: "agentmemory://status", + name: "Agent Memory Status", + description: "Current session count, memory count, and health status", + mimeType: "application/json", + }, + { + uri: "agentmemory://project/{name}/profile", + name: "Project Profile", + description: + "Top concepts, frequently modified files, and conventions for a project", + mimeType: "application/json", + }, + { + uri: "agentmemory://project/{name}/recent", + name: "Recent Sessions", + description: "Last 5 session summaries for a project", + mimeType: "application/json", + }, + { + uri: "agentmemory://memories/latest", + name: "Latest Memories", + description: "Top 10 latest memories with their type and strength", + mimeType: "application/json", + }, + { + uri: "agentmemory://graph/stats", + name: "Knowledge Graph Stats", + description: "Node and edge counts by type in the knowledge graph", + mimeType: "application/json", + }, + { + uri: "agentmemory://team/{id}/profile", + name: "Team Profile", + description: "Team memory profile with shared concepts and patterns", + mimeType: "application/json", + }, + ]; + + sdk.registerFunction("mcp::resources::list", + async (req: ApiRequest): Promise<McpResponse> => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + return { status_code: 200, body: { resources: MCP_RESOURCES } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "mcp::resources::list", + config: { api_path: "/agentmemory/mcp/resources", http_method: "GET" }, + }); + + sdk.registerFunction("mcp::resources::read", + async (req: ApiRequest<{ uri: string }>): Promise<McpResponse> => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + + const uri = req.body?.uri; + if (!uri || typeof uri !== "string") { + return { status_code: 400, body: { error: "uri is required" } }; + } + + try { + if (uri === "agentmemory://status") { + const sessions = await kv.list<Session>(KV.sessions); + const memories = await kv.list<Memory>(KV.memories); + const healthData = await kv.list(KV.health).catch(() => []); + return { + status_code: 200, + body: { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify({ + sessionCount: sessions.length, + memoryCount: memories.length, + healthStatus: + healthData.length > 0 ? "available" : "no-data", + }), + }, + ], + }, + }; + } + + const projectProfileMatch = uri.match( + /^agentmemory:\/\/project\/(.+)\/profile$/, + ); + if (projectProfileMatch) { + let projectName: string; + try { + projectName = decodeURIComponent(projectProfileMatch[1]); + } catch { + return { + status_code: 400, + body: { error: "Invalid percent-encoding in URI" }, + }; + } + const profile = await sdk.trigger({ function_id: "mem::profile", payload: { + project: projectName, + } }); + return { + status_code: 200, + body: { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify(profile), + }, + ], + }, + }; + } + + const projectRecentMatch = uri.match( + /^agentmemory:\/\/project\/(.+)\/recent$/, + ); + if (projectRecentMatch) { + let projectName: string; + try { + projectName = decodeURIComponent(projectRecentMatch[1]); + } catch { + return { + status_code: 400, + body: { error: "Invalid percent-encoding in URI" }, + }; + } + const summaries = await kv.list<SessionSummary>(KV.summaries); + const filtered = summaries + .filter((s) => s.project === projectName) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime(), + ) + .slice(0, 5); + return { + status_code: 200, + body: { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify(filtered), + }, + ], + }, + }; + } + + if (uri === "agentmemory://memories/latest") { + const memories = await kv.list<Memory>(KV.memories); + const latest = memories + .filter((m) => m.isLatest) + .sort( + (a, b) => + new Date(b.updatedAt).getTime() - + new Date(a.updatedAt).getTime(), + ) + .slice(0, 10) + .map((m) => ({ + id: m.id, + title: m.title, + type: m.type, + strength: m.strength, + })); + return { + status_code: 200, + body: { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify(latest), + }, + ], + }, + }; + } + + if (uri === "agentmemory://graph/stats") { + try { + const nodes = await kv.list<GraphNode>(KV.graphNodes); + const edges = await kv.list<GraphEdge>(KV.graphEdges); + const nodesByType: Record<string, number> = {}; + for (const n of nodes) + nodesByType[n.type] = (nodesByType[n.type] || 0) + 1; + const edgesByType: Record<string, number> = {}; + for (const e of edges) + edgesByType[e.type] = (edgesByType[e.type] || 0) + 1; + return { + status_code: 200, + body: { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify({ + totalNodes: nodes.length, + totalEdges: edges.length, + nodesByType, + edgesByType, + }), + }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify({ + totalNodes: 0, + totalEdges: 0, + }), + }, + ], + }, + }; + } + } + + const teamProfileMatch = uri.match( + /^agentmemory:\/\/team\/(.+)\/profile$/, + ); + if (teamProfileMatch) { + try { + const teamId = decodeURIComponent(teamProfileMatch[1]); + const items = await kv.list(KV.teamShared(teamId)); + return { + status_code: 200, + body: { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify({ + teamId, + sharedItems: items.length, + }), + }, + ], + }, + }; + } catch { + return { + status_code: 200, + body: { + contents: [ + { + uri, + mimeType: "application/json", + text: JSON.stringify({ + teamId: teamProfileMatch[1], + sharedItems: 0, + }), + }, + ], + }, + }; + } + } + + return { + status_code: 404, + body: { error: `Unknown resource: ${uri}` }, + }; + } catch { + return { status_code: 500, body: { error: "Internal error" } }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "mcp::resources::read", + config: { + api_path: "/agentmemory/mcp/resources/read", + http_method: "POST", + }, + }); + + const MCP_PROMPTS = [ + { + name: "recall_context", + description: + "Search observations and memories to build context for a task", + arguments: [ + { + name: "task_description", + description: "What you are working on", + required: true, + }, + ], + }, + { + name: "session_handoff", + description: + "Generate a handoff summary for continuing work in a new session", + arguments: [ + { + name: "session_id", + description: "Session ID to hand off from", + required: true, + }, + ], + }, + { + name: "detect_patterns", + description: "Detect recurring patterns across sessions for a project", + arguments: [ + { + name: "project", + description: "Project path to analyze (optional)", + required: false, + }, + ], + }, + ]; + + sdk.registerFunction("mcp::prompts::list", + async (req: ApiRequest): Promise<McpResponse> => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + return { status_code: 200, body: { prompts: MCP_PROMPTS } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "mcp::prompts::list", + config: { api_path: "/agentmemory/mcp/prompts", http_method: "GET" }, + }); + + sdk.registerFunction("mcp::prompts::get", + async ( + req: ApiRequest<{ name: string; arguments?: Record<string, string> }>, + ): Promise<McpResponse> => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + + const promptName = req.body?.name; + if (!promptName || typeof promptName !== "string") { + return { status_code: 400, body: { error: "name is required" } }; + } + + const promptArgs = req.body?.arguments || {}; + + try { + switch (promptName) { + case "recall_context": { + const taskDesc = promptArgs.task_description; + if (typeof taskDesc !== "string" || !taskDesc.trim()) { + return { + status_code: 400, + body: { + error: + "task_description argument is required and must be a string", + }, + }; + } + // #817: mem::search now enforces agent-scope upstream when + // AGENTMEMORY_AGENT_SCOPE=isolated, so the search half of + // this prompt is safe by default. + const searchResult = await sdk + .trigger({ + function_id: "mem::search", + payload: { query: taskDesc, limit: 10 }, + }) + .catch(() => ({ results: [] })); + const memories = await kv.list<Memory>(KV.memories); + // #817: also filter the memory list. recall_context's + // second source is the latest-memory feed, which leaks + // cross-agent rows when isolated mode is on. Mirror the + // search-side filter explicitly here; the upstream filter + // doesn't apply to a raw kv.list. + // + // Fail-closed: if isolated mode is on but no AGENT_ID is + // available, return an empty `relevant` array rather than + // letting every memory through. The mem::search call + // above already throws in this case, but the kv.list feed + // is a separate path that has to enforce isolation on its + // own. + const isolated = isAgentScopeIsolated(); + const activeAgentId = isolated ? getAgentId() : undefined; + const relevant = + isolated && activeAgentId === undefined + ? [] + : memories + .filter((m) => m.isLatest) + .filter( + (m) => + activeAgentId === undefined || + m.agentId === activeAgentId, + ) + .slice(0, 5); + return { + status_code: 200, + body: { + messages: [ + { + role: "user", + content: { + type: "text", + text: `Here is relevant context from past sessions for the task: "${taskDesc}"\n\n## Past Observations\n${JSON.stringify(searchResult, null, 2)}\n\n## Relevant Memories\n${JSON.stringify(relevant, null, 2)}`, + }, + }, + ], + }, + }; + } + + case "session_handoff": { + const sessionId = promptArgs.session_id; + if (typeof sessionId !== "string" || !sessionId.trim()) { + return { + status_code: 400, + body: { + error: "session_id argument is required and must be a string", + }, + }; + } + const session = await kv.get<Session>(KV.sessions, sessionId); + const summaries = await kv.list<SessionSummary>(KV.summaries); + const summary = summaries.find((s) => s.sessionId === sessionId); + return { + status_code: 200, + body: { + messages: [ + { + role: "user", + content: { + type: "text", + text: `## Session Handoff\n\n### Session\n${JSON.stringify(session, null, 2)}\n\n### Summary\n${JSON.stringify(summary || "No summary available", null, 2)}`, + }, + }, + ], + }, + }; + } + + case "detect_patterns": { + if ( + promptArgs.project !== undefined && + typeof promptArgs.project !== "string" + ) { + return { + status_code: 400, + body: { error: "project argument must be a string" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::patterns", payload: { + project: promptArgs.project || undefined, + } }); + return { + status_code: 200, + body: { + messages: [ + { + role: "user", + content: { + type: "text", + text: `## Pattern Analysis\n\n${JSON.stringify(result, null, 2)}`, + }, + }, + ], + }, + }; + } + + default: + return { + status_code: 400, + body: { error: `Unknown prompt: ${promptName}` }, + }; + } + } catch { + return { status_code: 500, body: { error: "Internal error" } }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "mcp::prompts::get", + config: { api_path: "/agentmemory/mcp/prompts/get", http_method: "POST" }, + }); +} diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts new file mode 100644 index 0000000..dd66ecb --- /dev/null +++ b/src/mcp/standalone.ts @@ -0,0 +1,501 @@ +#!/usr/bin/env node + +import { InMemoryKV } from "./in-memory-kv.js"; +import { createStdioTransport } from "./transport.js"; +import { getAllTools } from "./tools-registry.js"; +import { getStandalonePersistPath } from "../config.js"; +import { VERSION } from "../version.js"; +import { generateId } from "../state/schema.js"; +import { + resolveHandle, + invalidateHandle, + type Handle, + type ProxyHandle, +} from "./rest-proxy.js"; + +const IMPLEMENTED_TOOLS = new Set([ + "memory_save", + "memory_recall", + "memory_smart_search", + "memory_sessions", + "memory_export", + "memory_audit", + "memory_governance_delete", +]); + +const SERVER_INFO = { + name: "agentmemory", + version: VERSION, + protocolVersion: "2024-11-05", +}; + +const kv = new InMemoryKV(getStandalonePersistPath()); +let modeAnnounced = false; + +function displayAgentmemoryUrl(): string { + // Match the literal-placeholder guard in rest-proxy.ts so log lines + // don't show `${AGENTMEMORY_URL}` when an MCP host passed the + // placeholder through unexpanded. + const raw = process.env["AGENTMEMORY_URL"]; + if (!raw || (raw.startsWith("${") && raw.endsWith("}"))) { + return "http://localhost:3111"; + } + return raw; +} + +function announceMode(handle: Handle): void { + if (modeAnnounced) return; + modeAnnounced = true; + if (handle.mode === "proxy") { + process.stderr.write( + `[@agentmemory/mcp] proxying to agentmemory server at ${handle.baseUrl}\n`, + ); + } else { + const fullToolCount = getAllTools().length; + process.stderr.write( + `[@agentmemory/mcp] no server reachable at ${displayAgentmemoryUrl()}; running reduced LOCAL FALLBACK with ${IMPLEMENTED_TOOLS.size} of ${fullToolCount} tools. Start 'npx @agentmemory/agentmemory' (and point AGENTMEMORY_URL at it) to unlock all ${fullToolCount} tools.\n`, + ); + } +} + +function normalizeList(value: unknown): string[] { + if (!value) return []; + if (Array.isArray(value)) { + return value + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter((v) => v.length > 0); + } + if (typeof value === "string") { + return value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + return []; +} + +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 100; +function parseLimit(raw: unknown, fallback = DEFAULT_LIMIT): number { + if (typeof raw !== "number" && typeof raw !== "string") return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.min(Math.floor(n), MAX_LIMIT); +} + +function textResponse(payload: unknown, pretty = false): { + content: Array<{ type: string; text: string }>; +} { + return { + content: [ + { type: "text", text: JSON.stringify(payload, null, pretty ? 2 : 0) }, + ], + }; +} + +interface Validated { + tool: string; + content?: string; + type?: string; + concepts?: string[]; + files?: string[]; + query?: string; + limit?: number; + format?: string; + tokenBudget?: number; + memoryIds?: string[]; + reason?: string; +} + +function validate(toolName: string, args: Record<string, unknown>): Validated { + if (!IMPLEMENTED_TOOLS.has(toolName)) { + throw new Error(`Unknown tool: ${toolName}`); + } + const v: Validated = { tool: toolName }; + switch (toolName) { + case "memory_save": { + const content = args["content"]; + if (typeof content !== "string" || !content.trim()) { + throw new Error("content is required"); + } + v.content = content; + v.type = (args["type"] as string) || "fact"; + v.concepts = normalizeList(args["concepts"]); + v.files = normalizeList(args["files"]); + return v; + } + case "memory_recall": + case "memory_smart_search": { + const query = args["query"]; + if (typeof query !== "string" || !query.trim()) { + throw new Error("query is required"); + } + v.query = query.trim(); + v.limit = parseLimit(args["limit"]); + const fmt = args["format"]; + if (typeof fmt === "string" && fmt.trim()) { + v.format = fmt.trim().toLowerCase(); + } + const budget = args["token_budget"]; + if (typeof budget === "number" && Number.isFinite(budget) && budget > 0) { + v.tokenBudget = Math.floor(budget); + } else if (typeof budget === "string" && budget.trim()) { + const n = Number(budget); + if (Number.isFinite(n) && n > 0) v.tokenBudget = Math.floor(n); + } + return v; + } + case "memory_sessions": { + v.limit = parseLimit(args["limit"], 20); + return v; + } + case "memory_governance_delete": { + const ids = normalizeList(args["memoryIds"]); + if (ids.length === 0) throw new Error("memoryIds is required"); + v.memoryIds = ids; + v.reason = (args["reason"] as string) || "plugin skill request"; + return v; + } + case "memory_export": + return v; + case "memory_audit": { + v.limit = parseLimit(args["limit"], 50); + return v; + } + default: + throw new Error(`Unknown tool: ${toolName}`); + } +} + +async function handleProxy( + v: Validated, + handle: ProxyHandle, +): Promise<{ content: Array<{ type: string; text: string }> }> { + switch (v.tool) { + case "memory_save": { + const result = await handle.call("/agentmemory/remember", { + method: "POST", + body: JSON.stringify({ + content: v.content, + type: v.type, + concepts: v.concepts, + files: v.files, + }), + }); + return textResponse(result); + } + case "memory_recall": { + const body: Record<string, unknown> = { + query: v.query, + limit: v.limit, + format: v.format ?? "full", + }; + if (v.tokenBudget != null) body["token_budget"] = v.tokenBudget; + const result = await handle.call("/agentmemory/search", { + method: "POST", + body: JSON.stringify(body), + }); + return textResponse(result, true); + } + case "memory_smart_search": { + const body: Record<string, unknown> = { query: v.query, limit: v.limit }; + if (v.format != null) body["format"] = v.format; + if (v.tokenBudget != null) body["token_budget"] = v.tokenBudget; + const result = await handle.call("/agentmemory/smart-search", { + method: "POST", + body: JSON.stringify(body), + }); + return textResponse(result, true); + } + case "memory_sessions": { + const result = await handle.call( + `/agentmemory/sessions?limit=${v.limit}`, + { method: "GET" }, + ); + return textResponse(result, true); + } + case "memory_governance_delete": { + const result = await handle.call("/agentmemory/governance/memories", { + method: "DELETE", + body: JSON.stringify({ memoryIds: v.memoryIds, reason: v.reason }), + }); + return textResponse(result); + } + case "memory_export": { + const result = await handle.call("/agentmemory/export", { method: "GET" }); + return textResponse(result, true); + } + case "memory_audit": { + const result = await handle.call( + `/agentmemory/audit?limit=${v.limit}`, + { method: "GET" }, + ); + return textResponse(result, true); + } + default: + throw new Error(`Unknown tool: ${v.tool}`); + } +} + +async function handleLocal( + v: Validated, + kvInstance: InMemoryKV, +): Promise<{ content: Array<{ type: string; text: string }> }> { + switch (v.tool) { + case "memory_save": { + const id = generateId("mem"); + const isoNow = new Date().toISOString(); + await kvInstance.set("mem:memories", id, { + id, + type: v.type, + title: (v.content || "").slice(0, 80), + content: v.content, + concepts: v.concepts, + files: v.files, + createdAt: isoNow, + updatedAt: isoNow, + strength: 7, + version: 1, + isLatest: true, + sessionIds: [], + }); + kvInstance.persist(); + return textResponse({ saved: id }); + } + + case "memory_recall": + case "memory_smart_search": { + const query = (v.query || "").toLowerCase(); + const limit = v.limit ?? DEFAULT_LIMIT; + const all = + await kvInstance.list<Record<string, unknown>>("mem:memories"); + const results = all + .filter((m) => { + const text = [ + typeof m["title"] === "string" ? m["title"] : "", + typeof m["content"] === "string" ? m["content"] : "", + Array.isArray(m["files"]) ? m["files"].join(" ") : "", + Array.isArray(m["concepts"]) ? m["concepts"].join(" ") : "", + Array.isArray(m["sessionIds"]) ? m["sessionIds"].join(" ") : "", + typeof m["id"] === "string" ? m["id"] : "", + ] + .join(" ") + .toLowerCase(); + return query.split(/\s+/).every((word) => text.includes(word)); + }) + .slice(0, limit); + return textResponse({ mode: "compact", results }, true); + } + + case "memory_sessions": { + const sessions = + await kvInstance.list<Record<string, unknown>>("mem:sessions"); + const limit = v.limit ?? 20; + return textResponse({ sessions: sessions.slice(0, limit) }, true); + } + + case "memory_governance_delete": { + let deleted = 0; + for (const id of v.memoryIds || []) { + const existing = await kvInstance.get("mem:memories", id); + if (existing) { + await kvInstance.delete("mem:memories", id); + deleted++; + } + } + kvInstance.persist(); + return textResponse({ + deleted, + requested: (v.memoryIds || []).length, + reason: v.reason, + }); + } + + case "memory_export": { + const memories = await kvInstance.list("mem:memories"); + const sessions = await kvInstance.list("mem:sessions"); + return textResponse({ version: VERSION, memories, sessions }, true); + } + + case "memory_audit": { + const entries = await kvInstance.list("mem:audit"); + const limit = v.limit ?? 50; + return textResponse( + { + entries: (entries as Array<Record<string, unknown>>).slice(0, limit), + }, + true, + ); + } + + default: + throw new Error(`Unknown tool: ${v.tool}`); + } +} + +async function handleProxyGeneric( + toolName: string, + args: Record<string, unknown>, + handle: ProxyHandle, +): Promise<{ content: Array<{ type: string; text: string }> }> { + // Forward to the server's full MCP surface so non-Claude clients can + // reach all 53 tools (lessons, sentinels, slots, signals, graph, …) + // instead of being capped at the 7 IMPLEMENTED_TOOLS set baked into + // this shim. The server validates arguments per tool. + const result = (await handle.call("/agentmemory/mcp/call", { + method: "POST", + body: JSON.stringify({ name: toolName, arguments: args }), + })) as { content?: Array<{ type: string; text: string }> } | null; + if (result && Array.isArray(result.content)) { + return { content: result.content }; + } + return textResponse(result, true); +} + +export async function handleToolCall( + toolName: string, + args: Record<string, unknown>, + kvInstance: InMemoryKV = kv, +): Promise<{ content: Array<{ type: string; text: string }> }> { + const handle = await resolveHandle(); + announceMode(handle); + + // Tools the local InMemoryKV fallback doesn't implement: forward straight + // to the server. Local validation would otherwise raise "Unknown tool" + // (issue #234). + if (!IMPLEMENTED_TOOLS.has(toolName)) { + if (handle.mode === "proxy") { + try { + return await handleProxyGeneric(toolName, args, handle); + } catch (err) { + process.stderr.write( + `[@agentmemory/mcp] proxy call failed for ${toolName}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + invalidateHandle(); + throw err; + } + } + throw new Error( + `Unknown tool: ${toolName} (local fallback supports only ${[...IMPLEMENTED_TOOLS].join(", ")}; start an agentmemory server and set AGENTMEMORY_URL to use the full tool set)`, + ); + } + + const validated = validate(toolName, args); + if (handle.mode === "proxy") { + try { + return await handleProxy(validated, handle); + } catch (err) { + process.stderr.write( + `[@agentmemory/mcp] proxy call failed for ${toolName}: ${err instanceof Error ? err.message : String(err)}; invalidating handle and falling back to local KV\n`, + ); + invalidateHandle(); + } + } + return handleLocal(validated, kvInstance); +} + +export async function handleToolsList(): Promise<{ tools: unknown[] }> { + const debug = process.env["AGENTMEMORY_DEBUG"] === "1" || process.env["AGENTMEMORY_DEBUG"] === "true"; + const handle = await resolveHandle(); + announceMode(handle); + if (debug) { + process.stderr.write( + `[@agentmemory/mcp] tools/list: handle.mode=${handle.mode}${handle.mode === "proxy" ? ` baseUrl=${handle.baseUrl}` : ""}\n`, + ); + } + if (handle.mode === "proxy") { + try { + const remote = (await handle.call("/agentmemory/mcp/tools", { + method: "GET", + })) as { tools?: unknown } | null; + if (debug) { + const shape = remote === null + ? "null" + : typeof remote !== "object" + ? typeof remote + : `keys=${Object.keys(remote as object).join(",")} toolsType=${Array.isArray((remote as { tools?: unknown }).tools) ? `array(len=${((remote as { tools: unknown[] }).tools).length})` : typeof (remote as { tools?: unknown }).tools}`; + process.stderr.write( + `[@agentmemory/mcp] tools/list: remote response shape: ${shape}\n`, + ); + } + if (remote && Array.isArray(remote.tools)) { + if (debug) { + process.stderr.write( + `[@agentmemory/mcp] tools/list: returning ${remote.tools.length} tools from server\n`, + ); + } + return { tools: remote.tools }; + } + process.stderr.write( + `[@agentmemory/mcp] tools/list: server returned unexpected shape (no .tools array); falling back to local IMPLEMENTED_TOOLS list. Set AGENTMEMORY_DEBUG=1 to inspect response.\n`, + ); + } catch (err) { + process.stderr.write( + `[@agentmemory/mcp] tools/list proxy failed: ${err instanceof Error ? err.message : String(err)}; falling back to local list\n`, + ); + invalidateHandle(); + } + } + const fallback = getAllTools().filter((t) => IMPLEMENTED_TOOLS.has(t.name)); + if (debug) { + process.stderr.write( + `[@agentmemory/mcp] tools/list: returning ${fallback.length} local fallback tools (${fallback.map((t) => t.name).join(",")})\n`, + ); + } + return { tools: fallback }; +} + +const transport = createStdioTransport(async (method, params) => { + switch (method) { + case "initialize": + return { + protocolVersion: SERVER_INFO.protocolVersion, + capabilities: { tools: { listChanged: false } }, + serverInfo: { + name: SERVER_INFO.name, + version: SERVER_INFO.version, + }, + }; + + case "notifications/initialized": + return {}; + + case "tools/list": + return handleToolsList(); + + case "tools/call": { + const toolName = params.name as string; + const toolArgs = (params.arguments as Record<string, unknown>) || {}; + try { + return await handleToolCall(toolName, toolArgs); + } catch (err) { + return { + content: [ + { + type: "text", + text: `Error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + } + + default: + throw new Error(`Unknown method: ${method}`); + } +}); + +process.stderr.write( + `[@agentmemory/mcp] Standalone MCP server v${SERVER_INFO.version} starting...\n`, +); +transport.start(); + +process.on("SIGINT", () => { + kv.persist(); + process.exit(0); +}); +process.on("SIGTERM", () => { + kv.persist(); + process.exit(0); +}); diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts new file mode 100644 index 0000000..c4df349 --- /dev/null +++ b/src/mcp/tools-registry.ts @@ -0,0 +1,961 @@ +export type McpToolDef = { + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record<string, { type: string; description: string }>; + required?: string[]; + }; +}; + +export const CORE_TOOLS: McpToolDef[] = [ + { + name: "memory_recall", + description: + "Search past session observations for relevant context. Use when you need to recall what happened in previous sessions, find past decisions, or look up how a file was modified before.", + inputSchema: { + type: "object", + properties: { + query: { + type: "string", + description: "Search query (keywords, file names, concepts)", + }, + limit: { + type: "number", + description: "Max results to return (default 10)", + }, + format: { + type: "string", + description: "Result format: full, compact, or narrative (default full)", + }, + token_budget: { + type: "number", + description: "Optional token budget to trim returned results", + }, + }, + required: ["query"], + }, + }, + { + name: "memory_compress_file", + description: + "Compress a markdown file to reduce token usage while preserving headings, URLs, and code blocks. Creates a .original.md backup before writing.", + inputSchema: { + type: "object", + properties: { + filePath: { + type: "string", + description: "Path to the markdown file to compress", + }, + }, + required: ["filePath"], + }, + }, + { + name: "memory_save", + description: + "Explicitly save an important insight, decision, or pattern to long-term memory.", + inputSchema: { + type: "object", + properties: { + content: { + type: "string", + description: "The insight or decision to remember", + }, + type: { + type: "string", + description: + "Memory type: pattern, preference, architecture, bug, workflow, or fact", + }, + concepts: { + type: "string", + description: "Comma-separated key concepts", + }, + files: { + type: "string", + description: "Comma-separated relevant file paths", + }, + project: { + type: "string", + description: + "Stable canonical project identifier this memory belongs to (e.g. a slug, " + + "UUID, or registry key). Must match the value used when the session was " + + "started. Do not use filesystem paths or ad-hoc display names — those " + + "change across machines and will silently break project scoping.", + }, + }, + required: ["content"], + }, + }, + { + name: "memory_file_history", + description: "Get past observations about specific files.", + inputSchema: { + type: "object", + properties: { + files: { type: "string", description: "Comma-separated file paths" }, + sessionId: { + type: "string", + description: "Current session ID to exclude", + }, + }, + required: ["files"], + }, + }, + { + name: "memory_patterns", + description: "Detect recurring patterns across sessions.", + inputSchema: { + type: "object", + properties: { + project: { type: "string", description: "Project path to analyze" }, + }, + }, + }, + { + name: "memory_sessions", + description: + "List recent sessions with their status and observation counts.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "memory_smart_search", + description: "Hybrid semantic+keyword search with progressive disclosure.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + expandIds: { + type: "string", + description: "Comma-separated observation IDs to expand", + }, + limit: { type: "number", description: "Max results (default 10)" }, + }, + required: ["query"], + }, + }, + { + name: "memory_vision_search", + description: + "Cross-modal image search via CLIP embeddings. Pass queryText to find screenshots matching a description, or queryImageBase64/queryImageRef to find similar images. Requires AGENTMEMORY_IMAGE_EMBEDDINGS=true.", + inputSchema: { + type: "object", + properties: { + queryText: { type: "string", description: "Text query (e.g. 'login form with error banner')" }, + queryImageRef: { type: "string", description: "Absolute path to a stored image to match against" }, + queryImageBase64: { type: "string", description: "Raw base64 image bytes or data URL" }, + topK: { type: "number", description: "Max results (default 10, max 50)" }, + sessionId: { type: "string", description: "Filter to a single session" }, + }, + }, + }, + { + name: "memory_timeline", + description: "Chronological observations around an anchor point.", + inputSchema: { + type: "object", + properties: { + anchor: { + type: "string", + description: "Anchor point: ISO date or keyword", + }, + project: { type: "string", description: "Filter by project path" }, + before: { + type: "number", + description: "Observations before anchor (default 5)", + }, + after: { + type: "number", + description: "Observations after anchor (default 5)", + }, + }, + required: ["anchor"], + }, + }, + { + name: "memory_profile", + description: "User/project profile with top concepts and file patterns.", + inputSchema: { + type: "object", + properties: { + project: { type: "string", description: "Project path" }, + refresh: { + type: "string", + description: "Set to 'true' to force rebuild", + }, + }, + required: ["project"], + }, + }, + { + name: "memory_export", + description: "Export all memory data as JSON.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "memory_relations", + description: "Query the memory relationship graph.", + inputSchema: { + type: "object", + properties: { + memoryId: { + type: "string", + description: "Memory ID to find relations for", + }, + maxHops: { + type: "number", + description: "Max traversal depth (default 2)", + }, + minConfidence: { + type: "number", + description: "Min confidence (0-1, default 0)", + }, + }, + required: ["memoryId"], + }, + }, + { + name: "memory_commit_lookup", + description: + "Look up the agent session(s) that produced a specific git commit, given its SHA. Returns the commit metadata and linked sessions.", + inputSchema: { + type: "object", + properties: { + sha: { type: "string", description: "Full git commit SHA" }, + }, + required: ["sha"], + }, + }, + { + name: "memory_commits", + description: + "List recent commits linked to agent sessions, optionally filtered by branch or repo.", + inputSchema: { + type: "object", + properties: { + branch: { type: "string", description: "Filter by branch name" }, + repo: { type: "string", description: "Filter by remote URL" }, + limit: { type: "number", description: "Max results (default 100, max 500)" }, + }, + }, + }, +]; + +export const V040_TOOLS: McpToolDef[] = [ + { + name: "memory_claude_bridge_sync", + description: + "Sync memory state to/from Claude Code's native MEMORY.md file.", + inputSchema: { + type: "object", + properties: { + direction: { + type: "string", + description: + "'read' to import from MEMORY.md, 'write' to export to MEMORY.md", + }, + }, + required: ["direction"], + }, + }, + { + name: "memory_graph_query", + description: "Query the knowledge graph for entities and relationships.", + inputSchema: { + type: "object", + properties: { + startNodeId: { + type: "string", + description: "Starting node ID for traversal", + }, + nodeType: { type: "string", description: "Filter by node type" }, + maxDepth: { + type: "number", + description: "Max BFS depth (default 3, max 5)", + }, + query: { type: "string", description: "Search nodes by name" }, + }, + }, + }, + { + name: "memory_consolidate", + description: + "Run the 4-tier memory consolidation pipeline (working -> episodic -> semantic -> procedural).", + inputSchema: { + type: "object", + properties: { + tier: { + type: "string", + description: "Target tier: episodic, semantic, or procedural", + }, + }, + }, + }, + { + name: "memory_team_share", + description: "Share a memory or observation with team members.", + inputSchema: { + type: "object", + properties: { + itemId: { + type: "string", + description: "ID of memory or observation to share", + }, + itemType: { + type: "string", + description: "Type: observation, memory, or pattern", + }, + }, + required: ["itemId", "itemType"], + }, + }, + { + name: "memory_team_feed", + description: "Get recent shared items from all team members.", + inputSchema: { + type: "object", + properties: { + limit: { type: "number", description: "Max items (default 20)" }, + }, + }, + }, + { + name: "memory_audit", + description: "View the audit trail of memory operations.", + inputSchema: { + type: "object", + properties: { + operation: { type: "string", description: "Filter by operation type" }, + limit: { type: "number", description: "Max entries (default 50)" }, + }, + }, + }, + { + name: "memory_governance_delete", + description: "Delete specific memories with audit trail.", + inputSchema: { + type: "object", + properties: { + memoryIds: { + type: "string", + description: "Comma-separated memory IDs to delete", + }, + reason: { type: "string", description: "Reason for deletion" }, + }, + required: ["memoryIds"], + }, + }, + { + name: "memory_snapshot_create", + description: "Create a git-versioned snapshot of current memory state.", + inputSchema: { + type: "object", + properties: { + message: { type: "string", description: "Snapshot description" }, + }, + }, + }, +]; + +export const V050_TOOLS: McpToolDef[] = [ + { + name: "memory_action_create", + description: + "Create an actionable work item with typed dependencies. Actions track what agents need to do and how work items relate to each other.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Action title" }, + description: { + type: "string", + description: "Detailed description of the work", + }, + priority: { + type: "number", + description: "Priority 1-10 (10 highest)", + }, + project: { type: "string", description: "Project path" }, + tags: { + type: "string", + description: "Comma-separated tags", + }, + parentId: { + type: "string", + description: "Parent action ID for hierarchical actions", + }, + requires: { + type: "string", + description: + "Comma-separated action IDs that must complete before this", + }, + }, + required: ["title"], + }, + }, + { + name: "memory_action_update", + description: + "Update an action's status, priority, or details. Set status to 'done' to complete it and unblock dependent actions.", + inputSchema: { + type: "object", + properties: { + actionId: { type: "string", description: "Action ID to update" }, + status: { + type: "string", + description: "New status: pending, active, done, blocked, cancelled", + }, + result: { + type: "string", + description: "Outcome description (when completing)", + }, + priority: { type: "number", description: "New priority 1-10" }, + }, + required: ["actionId"], + }, + }, + { + name: "memory_frontier", + description: + "Get all unblocked actions ranked by priority and urgency. Returns the frontier of actionable work with no unsatisfied dependencies.", + inputSchema: { + type: "object", + properties: { + project: { type: "string", description: "Filter by project" }, + agentId: { + type: "string", + description: "Agent ID to check lease conflicts", + }, + limit: { type: "number", description: "Max results (default 20)" }, + }, + }, + }, + { + name: "memory_next", + description: + "Get the single most important next action to work on. Combines dependency resolution, priority, and recency into a score.", + inputSchema: { + type: "object", + properties: { + project: { type: "string", description: "Filter by project" }, + agentId: { type: "string", description: "Current agent ID" }, + }, + }, + }, + { + name: "memory_lease", + description: + "Acquire, release, or renew an exclusive lease on an action. Prevents multiple agents from working on the same thing.", + inputSchema: { + type: "object", + properties: { + actionId: { type: "string", description: "Action ID" }, + agentId: { type: "string", description: "Agent claiming the action" }, + operation: { + type: "string", + description: "acquire, release, or renew", + }, + result: { + type: "string", + description: "Result when releasing (marks action done)", + }, + ttlMs: { + type: "number", + description: "Lease duration in ms (default 10min, max 1hr)", + }, + }, + required: ["actionId", "agentId", "operation"], + }, + }, + { + name: "memory_routine_run", + description: + "Instantiate a frozen workflow routine, creating actions for each step with proper dependencies.", + inputSchema: { + type: "object", + properties: { + routineId: { type: "string", description: "Routine template ID" }, + project: { type: "string", description: "Project context" }, + initiatedBy: { type: "string", description: "Agent starting the run" }, + }, + required: ["routineId"], + }, + }, + { + name: "memory_signal_send", + description: + "Send a message to another agent or broadcast. Supports threading, typed messages, and TTL expiration.", + inputSchema: { + type: "object", + properties: { + from: { type: "string", description: "Sender agent ID" }, + to: { + type: "string", + description: "Recipient agent ID (omit for broadcast)", + }, + content: { type: "string", description: "Message content" }, + type: { + type: "string", + description: "Message type: info, request, response, alert, handoff", + }, + replyTo: { + type: "string", + description: "Signal ID to reply to (auto-threads)", + }, + }, + required: ["from", "content"], + }, + }, + { + name: "memory_signal_read", + description: + "Read messages for an agent. Marks delivered messages as read.", + inputSchema: { + type: "object", + properties: { + agentId: { type: "string", description: "Agent to read messages for" }, + unreadOnly: { + type: "string", + description: "Set to 'true' for unread only", + }, + threadId: { + type: "string", + description: "Filter by conversation thread", + }, + limit: { type: "number", description: "Max messages (default 50)" }, + }, + required: ["agentId"], + }, + }, + { + name: "memory_checkpoint", + description: + "Create or resolve an external checkpoint (CI result, approval, deploy status) that gates action progress.", + inputSchema: { + type: "object", + properties: { + operation: { + type: "string", + description: "create, resolve, or list", + }, + name: { type: "string", description: "Checkpoint name (for create)" }, + checkpointId: { + type: "string", + description: "Checkpoint ID (for resolve)", + }, + status: { + type: "string", + description: "passed or failed (for resolve)", + }, + type: { + type: "string", + description: "Checkpoint type: ci, approval, deploy, external, timer", + }, + linkedActionIds: { + type: "string", + description: + "Comma-separated action IDs this checkpoint gates (for create)", + }, + }, + required: ["operation"], + }, + }, + { + name: "memory_mesh_sync", + description: + "Sync memories and actions with peer agentmemory instances for multi-agent collaboration.", + inputSchema: { + type: "object", + properties: { + peerId: { + type: "string", + description: "Specific peer ID (omit for all)", + }, + direction: { + type: "string", + description: "push, pull, or both (default both)", + }, + }, + }, + }, +]; + +export const V051_TOOLS: McpToolDef[] = [ + { + name: "memory_sentinel_create", + description: + "Create an event-driven sentinel that watches for conditions (webhook, timer, threshold, pattern, approval) and auto-unblocks gated actions when triggered.", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "Sentinel name" }, + type: { + type: "string", + description: "Type: webhook, timer, threshold, pattern, approval, custom", + }, + config: { + type: "string", + description: "JSON config (timer: {durationMs}, threshold: {metric,operator,value}, pattern: {pattern}, webhook: {path})", + }, + linkedActionIds: { + type: "string", + description: "Comma-separated action IDs to gate", + }, + expiresInMs: { type: "number", description: "Auto-expire after ms" }, + }, + required: ["name", "type"], + }, + }, + { + name: "memory_sentinel_trigger", + description: + "Externally fire a sentinel, providing an optional result payload. Unblocks any gated actions.", + inputSchema: { + type: "object", + properties: { + sentinelId: { type: "string", description: "Sentinel ID to trigger" }, + result: { type: "string", description: "JSON result payload" }, + }, + required: ["sentinelId"], + }, + }, + { + name: "memory_sketch_create", + description: + "Create an ephemeral action graph for exploratory work. Auto-expires after TTL. Can be promoted to permanent actions or discarded.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Sketch title" }, + description: { type: "string", description: "What this sketch explores" }, + expiresInMs: { type: "number", description: "TTL in ms (default 1 hour)" }, + project: { type: "string", description: "Project context" }, + }, + required: ["title"], + }, + }, + { + name: "memory_sketch_promote", + description: + "Promote a sketch's ephemeral actions to permanent actions. Makes the exploratory work official.", + inputSchema: { + type: "object", + properties: { + sketchId: { type: "string", description: "Sketch ID to promote" }, + project: { type: "string", description: "Override project for promoted actions" }, + }, + required: ["sketchId"], + }, + }, + { + name: "memory_crystallize", + description: + "Compress completed action chains into compact crystal digests using LLM summarization. Extracts narrative, key outcomes, files affected, and lessons.", + inputSchema: { + type: "object", + properties: { + actionIds: { + type: "string", + description: "Comma-separated completed action IDs to crystallize", + }, + project: { type: "string", description: "Project context" }, + sessionId: { type: "string", description: "Session context" }, + }, + required: ["actionIds"], + }, + }, + { + name: "memory_diagnose", + description: + "Run health checks across all subsystems (actions, leases, sentinels, sketches, signals, sessions, memories, mesh). Identifies stuck, orphaned, and inconsistent state.", + inputSchema: { + type: "object", + properties: { + categories: { + type: "string", + description: "Comma-separated categories to check (default all)", + }, + }, + }, + }, + { + name: "memory_heal", + description: + "Auto-fix all fixable issues found by diagnostics. Unblocks stuck actions, expires stale leases, cleans up orphaned data.", + inputSchema: { + type: "object", + properties: { + categories: { + type: "string", + description: "Comma-separated categories to heal (default all)", + }, + dryRun: { + type: "string", + description: "Set to 'true' for dry run (report but don't fix)", + }, + }, + }, + }, + { + name: "memory_facet_tag", + description: + "Attach a structured tag (dimension:value) to an action, memory, or observation for multi-dimensional categorization.", + inputSchema: { + type: "object", + properties: { + targetId: { type: "string", description: "ID of the target to tag" }, + targetType: { + type: "string", + description: "Type: action, memory, or observation", + }, + dimension: { type: "string", description: "Tag dimension (e.g., priority, team, status)" }, + value: { type: "string", description: "Tag value (e.g., urgent, backend, reviewed)" }, + }, + required: ["targetId", "targetType", "dimension", "value"], + }, + }, + { + name: "memory_facet_query", + description: + "Query targets by facet tags with AND/OR logic. Find all actions tagged priority:urgent AND team:backend.", + inputSchema: { + type: "object", + properties: { + matchAll: { + type: "string", + description: "Comma-separated dimension:value pairs (AND logic)", + }, + matchAny: { + type: "string", + description: "Comma-separated dimension:value pairs (OR logic)", + }, + targetType: { + type: "string", + description: "Filter by type: action, memory, or observation", + }, + }, + }, + }, +]; + +export const V061_TOOLS: McpToolDef[] = [ + { + name: "memory_verify", + description: + "Verify a memory or observation by tracing its citation chain back to source observations and session context. Returns provenance information including confidence scores.", + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + description: "Memory ID or observation ID to verify", + }, + }, + required: ["id"], + }, + }, +]; + +export const V070_TOOLS: McpToolDef[] = [ + { + name: "memory_lesson_save", + description: + "Save a lesson learned from this session. Lessons have confidence scores that strengthen when reinforced and decay when not used. Duplicate content auto-strengthens the existing lesson.", + inputSchema: { + type: "object", + properties: { + content: { + type: "string", + description: "The lesson learned (what worked, what to avoid, when to use X approach)", + }, + context: { + type: "string", + description: "When/where this lesson applies", + }, + confidence: { + type: "number", + description: "Initial confidence 0.0-1.0 (default 0.5)", + }, + project: { type: "string", description: "Project this lesson is about" }, + tags: { type: "string", description: "Comma-separated tags" }, + }, + required: ["content"], + }, + }, + { + name: "memory_lesson_recall", + description: + "Search lessons by query. Returns lessons sorted by confidence and recency. Use to check what the agent has learned before making decisions.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + project: { type: "string", description: "Filter by project" }, + minConfidence: { + type: "number", + description: "Minimum confidence threshold (default 0.1)", + }, + limit: { type: "number", description: "Max results (default 10)" }, + }, + required: ["query"], + }, + }, + { + name: "memory_obsidian_export", + description: + "Export memories, lessons, and crystals as Obsidian-compatible Markdown files with YAML frontmatter and wikilinks for graph view.", + inputSchema: { + type: "object", + properties: { + vaultDir: { + type: "string", + description: "Output directory (default ~/.agentmemory/vault/)", + }, + types: { + type: "string", + description: "Comma-separated types to export: memories,lessons,crystals,sessions (default all)", + }, + }, + }, + }, +]; + +export const V073_TOOLS: McpToolDef[] = [ + { + name: "memory_reflect", + description: + "Traverse the knowledge graph, group related memories by concept clusters, and synthesize higher-order insights via LLM. Returns new and reinforced insights.", + inputSchema: { + type: "object", + properties: { + project: { type: "string", description: "Filter by project" }, + maxClusters: { + type: "number", + description: "Max concept clusters to process (default 10, max 20)", + }, + }, + }, + }, + { + name: "memory_insight_list", + description: + "List synthesized insights — higher-order observations derived from patterns across memories, lessons, and crystals.", + inputSchema: { + type: "object", + properties: { + project: { type: "string", description: "Filter by project" }, + minConfidence: { + type: "number", + description: "Minimum confidence threshold (default 0)", + }, + limit: { type: "number", description: "Max results (default 50)" }, + }, + }, + }, +]; + +export const V010_SLOTS_TOOLS: McpToolDef[] = [ + { + name: "memory_slot_list", + description: + "List all memory slots (pinned + project + global). Slots are editable, size-limited memory units the agent can read and modify across sessions.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "memory_slot_get", + description: "Read a single slot by label.", + inputSchema: { + type: "object", + properties: { + label: { type: "string", description: "Slot label (e.g. 'persona', 'pending_items')" }, + }, + required: ["label"], + }, + }, + { + name: "memory_slot_create", + description: "Create a new slot. Reject if a slot with the same label already exists.", + inputSchema: { + type: "object", + properties: { + label: { type: "string", description: "Slot label — lowercase, starts with letter, [a-z0-9_]" }, + content: { type: "string", description: "Initial content (default empty)" }, + sizeLimit: { type: "number", description: "Max chars (default 2000, hard cap 20000)" }, + description: { type: "string", description: "What this slot is for" }, + pinned: { type: "string", description: "'false' to exclude from context injection; default true" }, + scope: { type: "string", description: "'project' (default) or 'global' (shared across projects)" }, + }, + required: ["label"], + }, + }, + { + name: "memory_slot_append", + description: + "Append text to an existing slot. Fails with 413 if the append would exceed the slot's sizeLimit — agent must compact via memory_slot_replace first.", + inputSchema: { + type: "object", + properties: { + label: { type: "string", description: "Slot label" }, + text: { type: "string", description: "Text to append" }, + }, + required: ["label", "text"], + }, + }, + { + name: "memory_slot_replace", + description: "Replace slot content in place. Fails if content exceeds sizeLimit.", + inputSchema: { + type: "object", + properties: { + label: { type: "string", description: "Slot label" }, + content: { type: "string", description: "New full content" }, + }, + required: ["label", "content"], + }, + }, + { + name: "memory_slot_delete", + description: "Delete a slot. Seeded default slots can be deleted unless marked readOnly.", + inputSchema: { + type: "object", + properties: { + label: { type: "string", description: "Slot label" }, + }, + required: ["label"], + }, + }, +]; + +export const ESSENTIAL_TOOLS = new Set([ + "memory_save", + "memory_recall", + "memory_consolidate", + "memory_smart_search", + "memory_sessions", + "memory_diagnose", + "memory_lesson_save", + "memory_reflect", +]); + +export function getAllTools(): McpToolDef[] { + return [ + ...CORE_TOOLS, + ...V040_TOOLS, + ...V050_TOOLS, + ...V051_TOOLS, + ...V061_TOOLS, + ...V070_TOOLS, + ...V073_TOOLS, + ...V010_SLOTS_TOOLS, + ]; +} + +// default switched from "core" (8 essential tools) to "all" +// (full 53-tool surface). README and plugin manifests have always +// advertised 53 tools "in proxy mode"; the old default left OpenCode / +// Claude Code users seeing 8 with no indication the other tools existed. +// Users who want the lean essentials can still set AGENTMEMORY_TOOLS=core. +export function getVisibleTools(): McpToolDef[] { + const mode = process.env["AGENTMEMORY_TOOLS"] || "all"; + if (mode === "core") return getAllTools().filter((t) => ESSENTIAL_TOOLS.has(t.name)); + return getAllTools(); +} diff --git a/src/mcp/transport.ts b/src/mcp/transport.ts new file mode 100644 index 0000000..759ed01 --- /dev/null +++ b/src/mcp/transport.ts @@ -0,0 +1,263 @@ +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id?: string | number; + method: string; + params?: Record<string, unknown>; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: string | number | null; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +export type RequestHandler = ( + method: string, + params: Record<string, unknown>, +) => Promise<unknown>; + +export interface StdioMessageParser { + push: (chunk: Buffer | string) => void; + isFramed: () => boolean; +} + +// JSON-RPC 2.0 notifications are messages without an `id` field. The spec +// (and the MCP transport contract) requires the server to NOT send a +// response for notifications. Some clients tolerate spurious responses; +// stricter clients (e.g. Codex CLI) treat them as protocol violations and +// close the transport. See agentmemory#129. +function isNotification(req: JsonRpcRequest): boolean { + return req.id === undefined || req.id === null; +} + +// Per JSON-RPC 2.0 §4, a valid request id must be a String, Number, or Null +// (Null is technically only allowed in responses; in requests, omitting id +// is the convention for notifications, which we treat the same as null). +// Any other runtime type (object, array, boolean) is an Invalid Request. +function isValidId(id: unknown): id is string | number | null | undefined { + return ( + id === undefined || + id === null || + typeof id === "string" || + typeof id === "number" + ); +} + +// Exported for unit tests so the line-handling logic is exercised +// independently of process.stdin / process.stdout. +export async function processLine( + line: string, + handler: RequestHandler, + writeOut: (response: JsonRpcResponse) => void, + writeErr: (msg: string) => void = (msg) => process.stderr.write(msg), +): Promise<void> { + const trimmed = line.trim(); + if (!trimmed) return; + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + writeOut({ + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Parse error" }, + }); + return; + } + + const request = parsed as JsonRpcRequest; + const rawId = (request as { id?: unknown } | null)?.id; + + // Invalid request shape (missing/wrong jsonrpc, non-string method). + if ( + !request || + typeof request !== "object" || + request.jsonrpc !== "2.0" || + typeof request.method !== "string" + ) { + // Echo the id back only if it's a valid string/number. Notifications + // (missing/null id) and malformed ids both drop silently — we don't + // want to respond to something that could be a notification, and we + // can't invent an id for a malformed one. + if (typeof rawId === "string" || typeof rawId === "number") { + writeOut({ + jsonrpc: "2.0", + id: rawId, + error: { code: -32600, message: "Invalid Request" }, + }); + } + return; + } + + // Request shape is valid but id may still be of the wrong type + // (object, array, boolean). Per the spec, that's an Invalid Request. + // Respond with id: null because we can't safely echo a non-JSON-RPC id. + if (!isValidId(rawId)) { + writeOut({ + jsonrpc: "2.0", + id: null, + error: { code: -32600, message: "Invalid Request: id must be string, number, or null" }, + }); + return; + } + + const notification = isNotification(request); + + try { + const result = await handler(request.method, request.params || {}); + if (notification) return; + writeOut({ + jsonrpc: "2.0", + id: request.id as string | number, + result, + }); + } catch (err) { + if (notification) { + writeErr( + `[mcp-transport] notification handler error for ${request.method}: ${ + err instanceof Error ? err.message : String(err) + }\n`, + ); + return; + } + writeOut({ + jsonrpc: "2.0", + id: request.id as string | number, + error: { + code: -32603, + message: err instanceof Error ? err.message : String(err), + }, + }); + } +} + +function findHeaderEnd(buffer: Buffer): { headerEnd: number; bodyStart: number } | null { + const crlf = buffer.indexOf("\r\n\r\n"); + const lf = buffer.indexOf("\n\n"); + if (crlf === -1 && lf === -1) return null; + if (crlf !== -1 && (lf === -1 || crlf <= lf)) { + return { headerEnd: crlf, bodyStart: crlf + 4 }; + } + return { headerEnd: lf, bodyStart: lf + 2 }; +} + +function parseContentLength(header: string): number | null { + for (const line of header.split(/\r?\n/)) { + const match = line.match(/^content-length:\s*(\d+)\s*$/i); + if (match) return Number(match[1]); + } + return null; +} + +export function formatResponse( + response: JsonRpcResponse, + framed: boolean, +): string | Buffer[] { + const body = JSON.stringify(response); + if (!framed) return `${body}\n`; + const bytes = Buffer.from(body, "utf8"); + return [Buffer.from(`Content-Length: ${bytes.length}\r\n\r\n`, "ascii"), bytes]; +} + +export function createMessageParser( + onMessage: (message: string) => void, + writeErr: (msg: string) => void = (msg) => process.stderr.write(msg), +): StdioMessageParser { + let buffer = Buffer.alloc(0); + let framed = false; + + function processBuffer(): void { + while (buffer.length > 0) { + if (buffer[0] === 10 || buffer[0] === 13) { + buffer = buffer.subarray(1); + continue; + } + + const preview = buffer.toString("ascii", 0, Math.min(buffer.length, 32)); + if (/^content-length:/i.test(preview)) { + const header = findHeaderEnd(buffer); + if (!header) return; + + const headerText = buffer.subarray(0, header.headerEnd).toString("ascii"); + const contentLength = parseContentLength(headerText); + if (contentLength === null) { + writeErr("[mcp-transport] missing Content-Length header\n"); + buffer = buffer.subarray(header.bodyStart); + continue; + } + + const messageEnd = header.bodyStart + contentLength; + if (buffer.length < messageEnd) return; + + framed = true; + const message = buffer.subarray(header.bodyStart, messageEnd).toString("utf8"); + buffer = buffer.subarray(messageEnd); + onMessage(message); + continue; + } + + const newline = buffer.indexOf(10); + if (newline === -1) return; + const line = buffer + .subarray(0, newline) + .toString("utf8") + .replace(/\r$/, ""); + buffer = buffer.subarray(newline + 1); + onMessage(line); + } + } + + return { + push(chunk) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8"); + buffer = Buffer.concat([buffer, bytes]); + processBuffer(); + }, + isFramed() { + return framed; + }, + }; +} + +export function createStdioTransport(handler: RequestHandler): { + start: () => void; + stop: () => void; +} { + let parser: StdioMessageParser | null = null; + let queue = Promise.resolve(); + + const writeResponse = (response: JsonRpcResponse) => { + const formatted = formatResponse(response, parser?.isFramed() ?? false); + if (typeof formatted === "string") { + process.stdout.write(formatted); + return; + } + for (const chunk of formatted) { + process.stdout.write(chunk); + } + }; + + const onData = (chunk: Buffer) => parser?.push(chunk); + + return { + start() { + parser = createMessageParser((message) => { + queue = queue.then(() => processLine(message, handler, writeResponse)); + void queue.catch((err) => { + process.stderr.write( + `[mcp-transport] request processing failed: ${ + err instanceof Error ? err.message : String(err) + }\n`, + ); + }); + }); + process.stdin.on("data", onData); + }, + stop() { + process.stdin.off("data", onData); + parser = null; + }, + }; +} diff --git a/src/prompts/compression.ts b/src/prompts/compression.ts new file mode 100644 index 0000000..51183b0 --- /dev/null +++ b/src/prompts/compression.ts @@ -0,0 +1,67 @@ +export const COMPRESSION_SYSTEM = `You are a memory compression engine for an AI coding agent. Your job is to extract the essential information from a tool usage observation and compress it into structured data. + +Output EXACTLY this XML format with no additional text: + +<observation> + <type>one of: file_read, file_write, file_edit, command_run, search, web_fetch, conversation, error, decision, discovery, subagent, notification, task, other</type> + <title>Short descriptive title (max 80 chars) + One-line context (optional) + + Specific factual detail 1 + Specific factual detail 2 + + 2-3 sentence summary of what happened and why it matters + + technical concept or pattern + + + path/to/file + + 1-10 scale, 10 being critical architectural decision + + +Rules: +- Be concise but preserve ALL technically relevant details +- File paths must be exact +- Importance: 1-3 for routine reads, 4-6 for edits/commands, 7-9 for architectural decisions, 10 for breaking changes +- Concepts should be reusable search terms (e.g., "React hooks", "SQL migration", "auth middleware") +- Strip any secrets, tokens, or credentials from the output`; + +export function buildCompressionPrompt(observation: { + hookType: string; + toolName?: string; + toolInput?: unknown; + toolOutput?: unknown; + userPrompt?: string; + timestamp: string; +}): string { + const parts = [ + `Timestamp: ${observation.timestamp}`, + `Hook: ${observation.hookType}`, + ]; + + if (observation.toolName) parts.push(`Tool: ${observation.toolName}`); + if (observation.toolInput) { + const input = + typeof observation.toolInput === "string" + ? observation.toolInput + : JSON.stringify(observation.toolInput, null, 2); + parts.push(`Input:\n${truncate(input, 4000)}`); + } + if (observation.toolOutput) { + const output = + typeof observation.toolOutput === "string" + ? observation.toolOutput + : JSON.stringify(observation.toolOutput, null, 2); + parts.push(`Output:\n${truncate(output, 4000)}`); + } + if (observation.userPrompt) { + parts.push(`User prompt:\n${truncate(observation.userPrompt, 2000)}`); + } + + return parts.join("\n\n"); +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + "\n[...truncated]" : s; +} diff --git a/src/prompts/consolidation.ts b/src/prompts/consolidation.ts new file mode 100644 index 0000000..76580b8 --- /dev/null +++ b/src/prompts/consolidation.ts @@ -0,0 +1,48 @@ +export const SEMANTIC_MERGE_SYSTEM = `You are a memory consolidation engine. Given overlapping episodic memories (session summaries), extract stable factual knowledge. + +Output format (XML): + + Concise factual statement + + +Rules: +- Extract only facts that appear in 2+ episodes or are highly confident +- Confidence reflects how well-supported the fact is across episodes +- Combine overlapping information into single concise facts +- Skip ephemeral details (specific error messages, temporary states)`; + +export function buildSemanticMergePrompt( + episodes: Array<{ title: string; narrative: string; concepts: string[] }>, +): string { + const items = episodes + .map( + (e, i) => + `[Episode ${i + 1}]\nTitle: ${e.title}\nNarrative: ${e.narrative}\nConcepts: ${e.concepts.join(", ")}`, + ) + .join("\n\n"); + return `Consolidate these episodic memories into stable facts:\n\n${items}`; +} + +export const PROCEDURAL_EXTRACTION_SYSTEM = `You are a procedural memory extractor. Given repeated patterns and workflows observed across sessions, extract reusable procedures. + +Output format (XML): + + + Step 1 description + Step 2 description + + + +Rules: +- Only extract procedures observed 2+ times +- Steps should be concrete and actionable +- Trigger condition should be specific enough to match automatically`; + +export function buildProceduralExtractionPrompt( + patterns: Array<{ content: string; frequency: number }>, +): string { + const items = patterns + .map((p, i) => `[Pattern ${i + 1}] (seen ${p.frequency}x)\n${p.content}`) + .join("\n\n"); + return `Extract reusable procedures from these recurring patterns:\n\n${items}`; +} diff --git a/src/prompts/graph-extraction.ts b/src/prompts/graph-extraction.ts new file mode 100644 index 0000000..4f1049c --- /dev/null +++ b/src/prompts/graph-extraction.ts @@ -0,0 +1,35 @@ +export const GRAPH_EXTRACTION_SYSTEM = `You are a knowledge graph extraction engine. Given a compressed observation from a coding session, extract entities and relationships. + +Output format (XML): + + + value + + + + + + +Rules: +- Extract concrete entities only (real file paths, function names, library names) +- Use the most specific type available +- Weight relationships by how strong/direct the connection is +- If no entities found, output empty tags`; + +export function buildGraphExtractionPrompt( + observations: Array<{ + title: string; + narrative: string; + concepts: string[]; + files: string[]; + type: string; + }>, +): string { + const items = observations + .map( + (o, i) => + `[${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}`; +} diff --git a/src/prompts/reflect.ts b/src/prompts/reflect.ts new file mode 100644 index 0000000..6de8435 --- /dev/null +++ b/src/prompts/reflect.ts @@ -0,0 +1,55 @@ +export const REFLECT_SYSTEM = `You are a higher-order reasoning engine. Given a cluster of related concepts, facts, lessons, and action outcomes, synthesize cross-cutting insights that span multiple individual memories. + +Output format (XML): + + + The higher-order observation or principle. Should be actionable and non-obvious — something that only becomes visible when viewing multiple memories together. + + + +Rules: +- Identify patterns, principles, or strategies that span 2+ source items +- Confidence reflects how well-supported the insight is across sources +- Title should be a concise label (under 60 chars) +- Content should be the actual observation (1-3 sentences) +- Prefer actionable insights over abstract summaries +- Skip insights that merely restate a single source item +- Always emit confidence attribute before title attribute`; + +export function buildReflectPrompt(cluster: { + concepts: string[]; + facts: Array<{ fact: string; confidence: number }>; + lessons: Array<{ content: string; confidence: number }>; + crystalNarratives: string[]; +}): string { + const sections: string[] = []; + + sections.push(`## Concept Cluster: ${cluster.concepts.join(", ")}`); + + if (cluster.facts.length > 0) { + sections.push( + "\n## Known Facts", + ...cluster.facts.map( + (f) => `- [confidence=${f.confidence}] ${f.fact}`, + ), + ); + } + + if (cluster.lessons.length > 0) { + sections.push( + "\n## Lessons Learned", + ...cluster.lessons.map( + (l) => `- [confidence=${l.confidence}] ${l.content}`, + ), + ); + } + + if (cluster.crystalNarratives.length > 0) { + sections.push( + "\n## Completed Work Summaries", + ...cluster.crystalNarratives.map((n) => `- ${n}`), + ); + } + + return `Synthesize higher-order insights from this cluster of related memories:\n\n${sections.join("\n")}`; +} diff --git a/src/prompts/summary.ts b/src/prompts/summary.ts new file mode 100644 index 0000000..bd04021 --- /dev/null +++ b/src/prompts/summary.ts @@ -0,0 +1,87 @@ +export const SUMMARY_SYSTEM = `You are a session summarizer for an AI coding agent's memory system. Given all compressed observations from a coding session, produce a concise session summary. + +Output EXACTLY this XML format with no additional text: + + + Short session title (max 100 chars) + 3-5 sentence narrative of what was accomplished + + Key technical decision made + + + path/to/modified/file + + + key concept from session + + + +Rules: +- Focus on outcomes, not individual tool calls +- Highlight decisions and their rationale +- List all files that were created or modified +- Concepts should be searchable terms for future context retrieval` + +export function buildSummaryPrompt(observations: Array<{ + type: string + title: string + facts: string[] + narrative: string + files: string[] + concepts: string[] +}>): string { + const lines = observations.map((obs, i) => { + const facts = obs.facts.map((f) => ` - ${f}`).join('\n') + return `[${i + 1}] ${obs.type}: ${obs.title}\n${obs.narrative}\nFacts:\n${facts}\nFiles: ${obs.files.join(', ')}` + }) + return `Session observations (${observations.length} total):\n\n${lines.join('\n\n---\n\n')}` +} + +export const REDUCE_SYSTEM = `You are merging multiple partial summaries of the SAME coding session into one final session summary. The partials are chronological chunks of one continuous session — not separate sessions. + +Output EXACTLY this XML format with no additional text: + + + Short session title (max 100 chars) + 3-5 sentence narrative covering the whole session + + Key technical decision made + + + path/to/modified/file + + + key concept from session + + + +Rules: +- Synthesize a single narrative that reflects the whole arc, not a chunk-by-chunk recap +- Preserve every distinct decision across chunks +- Union (deduplicate) all files and concepts +- Title should capture the session's overall outcome` + +export function buildReducePrompt(partials: Array<{ + title: string + narrative: string + keyDecisions: string[] + filesModified: string[] + concepts: string[] + obsRangeStart: number + obsRangeEnd: number +}>): string { + const sections = partials.map((p, i) => { + const decisions = p.keyDecisions.map((d) => ` - ${d}`).join('\n') + const files = p.filesModified.map((f) => ` - ${f}`).join('\n') + const concepts = p.concepts.join(', ') + return `[Chunk ${i + 1} of ${partials.length} — obs ${p.obsRangeStart}-${p.obsRangeEnd}] +Title: ${p.title} +Narrative: ${p.narrative} +Decisions: +${decisions} +Files: +${files} +Concepts: ${concepts}` + }) + return `Partial summaries (${partials.length} chunks of one session, chronological):\n\n${sections.join('\n\n---\n\n')}` +} diff --git a/src/prompts/vision.ts b/src/prompts/vision.ts new file mode 100644 index 0000000..4472f82 --- /dev/null +++ b/src/prompts/vision.ts @@ -0,0 +1,8 @@ +export const VISION_DESCRIPTION_PROMPT = `Describe what this image shows in the context of software development. Extract: +- What type of image this is (screenshot, diagram, mockup, terminal output, error, etc.) +- Key entities visible (files, components, UI elements, error messages) +- Relationships or flow shown +- Any decisions, errors, or state visible +- Text content visible in the image + +Be concise but preserve all technically relevant details. Output plain text, no XML.`; diff --git a/src/prompts/xml.ts b/src/prompts/xml.ts new file mode 100644 index 0000000..ed8f572 --- /dev/null +++ b/src/prompts/xml.ts @@ -0,0 +1,26 @@ +const VALID_TAG = /^[a-zA-Z_][a-zA-Z0-9_-]*$/; + +export function getXmlTag(xml: string, tag: string): string { + if (!VALID_TAG.test(tag)) return ""; + const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)`)); + return match ? match[1].trim() : ""; +} + +export function getXmlChildren( + xml: string, + parentTag: string, + childTag: string, +): string[] { + if (!VALID_TAG.test(parentTag) || !VALID_TAG.test(childTag)) return []; + const parentMatch = xml.match( + new RegExp(`<${parentTag}>([\\s\\S]*?)`), + ); + if (!parentMatch) return []; + const items: string[] = []; + const re = new RegExp(`<${childTag}>([\\s\\S]*?)`, "g"); + let m; + while ((m = re.exec(parentMatch[1])) !== null) { + items.push(m[1].trim()); + } + return items; +} diff --git a/src/providers/_fetch.ts b/src/providers/_fetch.ts new file mode 100644 index 0000000..ed9b5e8 --- /dev/null +++ b/src/providers/_fetch.ts @@ -0,0 +1,19 @@ +import { getEnvVar } from "../config.js"; + +export function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs?: number, +): Promise { + const parsed = + timeoutMs ?? + Number.parseInt(getEnvVar("AGENTMEMORY_LLM_TIMEOUT_MS") ?? "60000", 10); + const ms = Number.isFinite(parsed) && parsed > 0 ? parsed : 60000; + + const ctl = new AbortController(); + const signal = init.signal + ? AbortSignal.any([init.signal, ctl.signal]) + : ctl.signal; + const t = setTimeout(() => ctl.abort(), ms); + return fetch(url, { ...init, signal }).finally(() => clearTimeout(t)); +} diff --git a/src/providers/_openai-shared.ts b/src/providers/_openai-shared.ts new file mode 100644 index 0000000..09566b1 --- /dev/null +++ b/src/providers/_openai-shared.ts @@ -0,0 +1,166 @@ +// Shared transport helpers for the OpenAI-compatible LLM + embedding +// providers. Both surfaces (chat completions, embeddings) speak the +// same wire shape on the standard OpenAI path. Azure OpenAI ships +// two URL styles and we support both: +// +// - Legacy/stable: `/openai/deployments//chat/completions` +// with mandatory `api-version=` query param. The deployment +// name lives in the URL path. Required api-version moves with +// every Azure date-stamped revision; we keep the +// `OPENAI_API_VERSION` env knob + `DEFAULT_AZURE_API_VERSION` +// fallback so existing configs don't break on upgrade. +// +// - v1 (GA Apr-2025): `/openai/v1/chat/completions`. No +// api-version query param, no `/deployments/` segment. Deployment +// name is passed in the request body as `model`. This matches +// the OpenAI wire shape one-for-one which is the whole point of +// v1 — drop-in compatibility. +// +// Auto-detection runs off the URL shape, not a flag: if the path +// already carries `/deployments/`, we route through the legacy +// builder; otherwise v1. Users opt into v1 by stripping the +// `/openai/deployments/` suffix from their +// OPENAI_BASE_URL (or never adding it). See azureStyleOf(). + +export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com"; + +// Default api-version for the legacy Azure URL pattern. Only used +// when the configured base URL carries `/deployments/` AND +// OPENAI_API_VERSION is unset. The v1 path ignores this entirely. +export const DEFAULT_AZURE_API_VERSION = "2024-08-01-preview"; + +type AzureStyle = "legacy" | "v1"; + +// Azure resource URLs land at .openai.azure.com. +export function detectAzure(baseUrl: string): boolean { + try { + const u = new URL(baseUrl); + return u.hostname.endsWith(".openai.azure.com"); + } catch { + return false; + } +} + +// Pick the Azure URL style off the base URL's path shape. We only +// consult this when detectAzure(baseUrl) has already returned true. +function azureStyleOf(baseUrl: string): AzureStyle { + try { + const u = new URL(baseUrl); + // `/openai/deployments/` (with or without a trailing + // segment) signals the legacy URL pattern. Anything else — a + // bare resource host, an `/openai/v1` prefix, an empty path — + // routes through v1. + if (/\/openai\/deployments\//.test(u.pathname)) return "legacy"; + return "v1"; + } catch { + return "v1"; + } +} + +// Legacy Azure: append the route to the existing deployment path, +// set api-version via searchParams. URL-API composition keeps any +// pre-existing query params on the base URL intact. +function legacyAzureUrl( + baseUrl: string, + path: string, + apiVersion: string, +): string { + const url = new URL(baseUrl); + const existing = url.pathname.replace(/\/+$/, ""); + const route = path.startsWith("/") ? path : `/${path}`; + url.pathname = `${existing}${route}`; + url.searchParams.set("api-version", apiVersion); + return url.toString(); +} + +// v1 Azure: route through `/openai/v1/`. Preserve any existing +// query params on the base URL (corporate proxy, diagnostics tokens) +// but never append api-version — v1 doesn't accept it. +function v1AzureUrl(baseUrl: string, path: string): string { + const url = new URL(baseUrl); + const route = path.startsWith("/") ? path.slice(1) : path; + // Strip any trailing `/openai`, `/openai/`, or `/openai/v1` so a + // user who configures OPENAI_BASE_URL with a partial prefix still + // gets a single, correct path. + const base = url.pathname.replace(/\/?openai(?:\/v1)?\/?$/, ""); + url.pathname = `${base.replace(/\/+$/, "")}/openai/v1/${route}`; + return url.toString(); +} + +// Append an OpenAI-compatible route to the base URL without producing +// a doubled version segment. Three cases: +// +// 1. Empty path (just a hostname) — OpenAI default: prepend `/v1/`. +// https://api.openai.com → https://api.openai.com/v1/chat/completions +// 2. Path already ends with `/v1` — provider-documented base, treat +// it as the version anchor and append the route directly. +// https://api.deepseek.com/v1 → https://api.deepseek.com/v1/chat/completions +// (without this, we'd produce /v1/v1/chat/completions and 404. #628) +// 3. Path ends with any other versioned or path segment — provider +// uses a non-OpenAI version scheme (Zhipu /api/paas/v4, others) — +// append the route directly, no `/v1/` injected. +// https://open.bigmodel.cn/api/paas/v4 → .../v4/chat/completions (#646) +function appendOpenAIRoute(baseUrl: string, route: string): string { + const trimmedBase = baseUrl.replace(/\/+$/, ""); + const cleanRoute = route.startsWith("/") ? route : `/${route}`; + let pathname: string; + try { + pathname = new URL(trimmedBase).pathname.replace(/\/+$/, ""); + } catch { + return `${trimmedBase}/v1${cleanRoute}`; + } + if (pathname === "" || pathname === "/") { + return `${trimmedBase}/v1${cleanRoute}`; + } + return `${trimmedBase}${cleanRoute}`; +} + +export function buildChatUrl( + baseUrl: string, + isAzure: boolean, + azureApiVersion: string, +): string { + if (isAzure) { + return azureStyleOf(baseUrl) === "legacy" + ? legacyAzureUrl(baseUrl, "/chat/completions", azureApiVersion) + : v1AzureUrl(baseUrl, "/chat/completions"); + } + return appendOpenAIRoute(baseUrl, "/chat/completions"); +} + +export function buildEmbeddingUrl( + baseUrl: string, + isAzure: boolean, + azureApiVersion: string, +): string { + if (isAzure) { + return azureStyleOf(baseUrl) === "legacy" + ? legacyAzureUrl(baseUrl, "/embeddings", azureApiVersion) + : v1AzureUrl(baseUrl, "/embeddings"); + } + return appendOpenAIRoute(baseUrl, "/embeddings"); +} + +// Azure key-auth uses `api-key: `; standard OpenAI-compatible +// endpoints use `Authorization: Bearer `. Azure also accepts +// Bearer when AAD-auth is configured upstream, but the api-key path +// is the default and what our config block documents. +export function buildAuthHeaders( + apiKey: string, + isAzure: boolean, +): Record { + if (isAzure) { + return { + "Content-Type": "application/json", + "api-key": apiKey, + }; + } + return { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; +} + +export function normalizeBaseUrl(raw: string | undefined): string { + return (raw || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, ""); +} diff --git a/src/providers/agent-sdk.ts b/src/providers/agent-sdk.ts new file mode 100644 index 0000000..06d5946 --- /dev/null +++ b/src/providers/agent-sdk.ts @@ -0,0 +1,120 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { MemoryProvider } from '../types.js' + +// #781: the recursion guard used to live on `process.env.AGENTMEMORY_SDK_CHILD` +// (#181). #472 then introduced chunked summarize that runs chunks +// concurrently in the same process via Promise.all. The first chunk +// flipped the global env to "1" synchronously before its `await`, and +// every sibling chunk in the same batch immediately bailed out as a +// "child" — returning "" — so half-plus of the chunks failed to parse +// and the summarize threw `too_many_chunks_skipped: N/N`. +// +// Split the guard so each concern uses the right primitive: +// +// - **In-process** recursion guard: AsyncLocalStorage. Scoped to the +// async call tree of the SDK query, so concurrent siblings on the +// same provider instance no longer see each other's marker. +// - **Cross-process** recursion guard for hooks: still +// `process.env.AGENTMEMORY_SDK_CHILD = "1"` around the SDK call. +// Subprocesses spawned by `@anthropic-ai/claude-agent-sdk` inherit +// `process.env` at spawn time, so the hook scripts (which run as +// separate processes) still see the marker and skip their REST +// callback to /summarize. ALS does not cross process boundaries. +const sdkChildContext = new AsyncLocalStorage() + +// Module-level refcount for the process.env marker. A per-call snapshot +// races across overlapping calls: A saves prev=undef, B saves prev="1", +// A's finally restores undef while B is still mid-flight (so any child +// process B spawns won't inherit the marker), and B's finally restores +// "1" — leaking the marker into the global env after the last caller. +// Reference-count instead so only the first entrant snapshots the +// original value and only the last exit restores it. +let sdkActiveCount = 0 +let sdkOriginalEnv: string | undefined + +type ClaudeAgentSdkModule = typeof import('@anthropic-ai/claude-agent-sdk') + +export class AgentSDKProvider implements MemoryProvider { + name = 'agent-sdk' + + // Memoize the dynamic import so concurrent callers share one resolution + // instead of racing to resolve the specifier independently. Keeps the + // SDK out of the cold-start path for users on other providers. + private sdkPromise: Promise | null = null + + private loadSdk(): Promise { + if (!this.sdkPromise) { + this.sdkPromise = import('@anthropic-ai/claude-agent-sdk') + } + return this.sdkPromise + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.query(systemPrompt, userPrompt) + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.query(systemPrompt, userPrompt) + } + + private async query(systemPrompt: string, userPrompt: string): Promise { + // In-process recursion guard. Concurrent sibling calls (chunked + // summarize via Promise.all) each have their own ALS frame, so they + // do not poison each other. + if (sdkChildContext.getStore()) { + // We are already inside a Claude Agent SDK-spawned async call + // tree. Spawning another one would let its plugin-hook-driven + // Stop loop re-enter /agentmemory/summarize and cause unbounded + // recursion (#149 follow-up). Degrade to empty string so callers + // short-circuit. The chunk retry path in src/functions/summarize.ts + // treats "" as a parse failure but only the in-process re-entry + // path can reach this branch — legitimate concurrent siblings now + // run with their own ALS frames. + return '' + } + + return sdkChildContext.run(true, async () => { + // Mark spawned subprocesses (the SDK's underlying Claude session + // + its hook scripts) as SDK children via process.env. Hook scripts + // run in separate processes and read process.env to short-circuit + // their REST callbacks. Reference-counted so overlapping calls + // don't race each other into restoring stale values. + if (sdkActiveCount === 0) { + sdkOriginalEnv = process.env.AGENTMEMORY_SDK_CHILD + process.env.AGENTMEMORY_SDK_CHILD = '1' + } + sdkActiveCount++ + + try { + const { query } = await this.loadSdk() + + const messages = query({ + prompt: userPrompt, + options: { + systemPrompt, + maxTurns: 1, + allowedTools: [], + }, + }) + + let result = '' + for await (const msg of messages) { + if (msg.type === 'result') { + result = (msg as any).result ?? '' + } + } + return result + } finally { + sdkActiveCount-- + if (sdkActiveCount === 0) { + if (sdkOriginalEnv === undefined) { + delete process.env.AGENTMEMORY_SDK_CHILD + } else { + process.env.AGENTMEMORY_SDK_CHILD = sdkOriginalEnv + } + sdkOriginalEnv = undefined + } + } + }) + } +} diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts new file mode 100644 index 0000000..6dc5cfd --- /dev/null +++ b/src/providers/anthropic.ts @@ -0,0 +1,55 @@ +import Anthropic from '@anthropic-ai/sdk' +import type { MemoryProvider } from '../types.js' + +export class AnthropicProvider implements MemoryProvider { + name = 'anthropic' + private client: Anthropic + private model: string + private maxTokens: number + + constructor(apiKey: string, model: string, maxTokens: number, baseURL?: string) { + this.client = new Anthropic({ apiKey, ...(baseURL ? { baseURL } : {}) }) + this.model = model + this.maxTokens = maxTokens + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt) + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt) + } + + async describeImage(imageData: string, mimeType: string, prompt: string): Promise { + const response = await this.client.messages.create({ + model: this.model, + max_tokens: this.maxTokens, + messages: [{ + role: 'user', + content: [ + { + type: 'image', + source: { type: 'base64', media_type: mimeType as 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp', data: imageData }, + }, + { type: 'text', text: prompt }, + ], + }], + }) + + const textBlock = response.content.find((b) => b.type === 'text') + return textBlock?.text ?? '' + } + + private async call(systemPrompt: string, userPrompt: string): Promise { + const response = await this.client.messages.create({ + model: this.model, + max_tokens: this.maxTokens, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }) + + const textBlock = response.content.find((b) => b.type === 'text') + return textBlock?.text ?? '' + } +} diff --git a/src/providers/circuit-breaker.ts b/src/providers/circuit-breaker.ts new file mode 100644 index 0000000..e7f1f85 --- /dev/null +++ b/src/providers/circuit-breaker.ts @@ -0,0 +1,82 @@ +import type { CircuitBreakerState } from "../types.js"; + +interface CircuitBreakerOptions { + failureThreshold?: number; + failureWindowMs?: number; + recoveryTimeoutMs?: number; +} + +function positiveFinite(val: number | undefined, fallback: number): number { + return Number.isFinite(val) && val! > 0 ? val! : fallback; +} + +export class CircuitBreaker { + private state: "closed" | "open" | "half-open" = "closed"; + private failures = 0; + private lastFailureAt: number | null = null; + private openedAt: number | null = null; + + private readonly failureThreshold: number; + private readonly failureWindowMs: number; + private readonly recoveryTimeoutMs: number; + + constructor(opts?: CircuitBreakerOptions) { + this.failureThreshold = Math.max( + 1, + Math.floor(positiveFinite(opts?.failureThreshold, 3)), + ); + this.failureWindowMs = positiveFinite(opts?.failureWindowMs, 60_000); + this.recoveryTimeoutMs = positiveFinite(opts?.recoveryTimeoutMs, 30_000); + } + + get isAllowed(): boolean { + if (this.state === "closed") return true; + if (this.state === "open") { + if ( + this.openedAt && + Date.now() - this.openedAt >= this.recoveryTimeoutMs + ) { + this.state = "half-open"; + return true; + } + return false; + } + return true; + } + + recordSuccess(): void { + if (this.state === "half-open") { + this.state = "closed"; + this.failures = 0; + this.lastFailureAt = null; + this.openedAt = null; + } + } + + recordFailure(): void { + const now = Date.now(); + if (this.state === "half-open") { + this.state = "open"; + this.openedAt = now; + return; + } + if (this.lastFailureAt && now - this.lastFailureAt > this.failureWindowMs) { + this.failures = 0; + } + this.failures += 1; + this.lastFailureAt = now; + if (this.failures >= this.failureThreshold) { + this.state = "open"; + this.openedAt = now; + } + } + + getState(): CircuitBreakerState { + return { + state: this.state, + failures: this.failures, + lastFailureAt: this.lastFailureAt, + openedAt: this.openedAt, + }; + } +} diff --git a/src/providers/embedding/clip.ts b/src/providers/embedding/clip.ts new file mode 100644 index 0000000..2a0143a --- /dev/null +++ b/src/providers/embedding/clip.ts @@ -0,0 +1,107 @@ +import { readFile } from "node:fs/promises"; +import type { EmbeddingProvider } from "../../types.js"; + +type TransformersModule = { + pipeline: ( + task: string, + model: string, + ) => Promise; + RawImage: { + fromBlob: (blob: Blob) => Promise; + }; +}; + +type RawImageInstance = unknown; + +type ClipPipeline = ( + input: string[] | RawImageInstance | RawImageInstance[], + options?: { pooling?: string; normalize?: boolean }, +) => Promise<{ tolist: () => number[][]; data: Float32Array }>; + +const DEFAULT_MODEL = "Xenova/clip-vit-base-patch32"; +const DIMENSIONS = 512; + +export class ClipEmbeddingProvider implements EmbeddingProvider { + readonly name = "clip"; + readonly dimensions = DIMENSIONS; + private textExtractor: ClipPipeline | null = null; + private imageExtractor: ClipPipeline | null = null; + private transformers: TransformersModule | null = null; + private readonly modelId: string; + + constructor(modelId: string = DEFAULT_MODEL) { + this.modelId = modelId; + } + + async embed(text: string): Promise { + const [vec] = await this.embedBatch([text]); + return vec; + } + + async embedBatch(texts: string[]): Promise { + const extractor = await this.getTextExtractor(); + const output = await extractor(texts, { pooling: "mean", normalize: true }); + return output.tolist().map((v) => new Float32Array(v)); + } + + async embedImage(src: string): Promise { + const t = await this.getTransformers(); + const image = await loadImage(t, src); + const extractor = await this.getImageExtractor(); + const output = await extractor(image); + const vec = output.data ?? new Float32Array(output.tolist()[0] || []); + return normalize(vec); + } + + private async getTransformers(): Promise { + if (this.transformers) return this.transformers; + try { + this.transformers = (await import("@xenova/transformers")) as unknown as TransformersModule; + } catch { + throw new Error( + "Install @xenova/transformers for CLIP image embeddings: npm install @xenova/transformers", + ); + } + return this.transformers; + } + + private async getTextExtractor(): Promise { + if (this.textExtractor) return this.textExtractor; + const t = await this.getTransformers(); + this.textExtractor = await t.pipeline("feature-extraction", this.modelId); + return this.textExtractor; + } + + private async getImageExtractor(): Promise { + if (this.imageExtractor) return this.imageExtractor; + const t = await this.getTransformers(); + this.imageExtractor = await t.pipeline("image-feature-extraction", this.modelId); + return this.imageExtractor; + } +} + +async function loadImage( + t: TransformersModule, + src: string, +): Promise { + if (src.startsWith("data:")) { + const comma = src.indexOf(","); + const b64 = comma >= 0 ? src.slice(comma + 1) : src; + const buf = Buffer.from(b64, "base64"); + const blob = new Blob([buf]); + return t.RawImage.fromBlob(blob); + } + const data = await readFile(src); + const blob = new Blob([data]); + return t.RawImage.fromBlob(blob); +} + +function normalize(vec: Float32Array): Float32Array { + let sum = 0; + for (let i = 0; i < vec.length; i++) sum += vec[i] * vec[i]; + const norm = Math.sqrt(sum); + if (norm === 0) return vec; + const out = new Float32Array(vec.length); + for (let i = 0; i < vec.length; i++) out[i] = vec[i] / norm; + return out; +} diff --git a/src/providers/embedding/cohere.ts b/src/providers/embedding/cohere.ts new file mode 100644 index 0000000..02b7f13 --- /dev/null +++ b/src/providers/embedding/cohere.ts @@ -0,0 +1,47 @@ +import type { EmbeddingProvider } from "../../types.js"; +import { getEnvVar } from "../../config.js"; +import { fetchWithTimeout } from "../_fetch.js"; + +const API_URL = "https://api.cohere.ai/v1/embed"; + +export class CohereEmbeddingProvider implements EmbeddingProvider { + readonly name = "cohere"; + readonly dimensions = 1024; + private apiKey: string; + + constructor(apiKey?: string) { + this.apiKey = apiKey || getEnvVar("COHERE_API_KEY") || ""; + if (!this.apiKey) throw new Error("COHERE_API_KEY is required"); + } + + async embed(text: string): Promise { + const [result] = await this.embedBatch([text]); + return result; + } + + async embedBatch(texts: string[]): Promise { + const response = await fetchWithTimeout(API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "embed-english-v3.0", + texts, + input_type: "search_document", + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Cohere embedding failed (${response.status}): ${err}`); + } + + const data = (await response.json()) as { + embeddings: number[][]; + }; + + return data.embeddings.map((e) => new Float32Array(e)); + } +} diff --git a/src/providers/embedding/gemini.ts b/src/providers/embedding/gemini.ts new file mode 100644 index 0000000..1401646 --- /dev/null +++ b/src/providers/embedding/gemini.ts @@ -0,0 +1,78 @@ +import type { EmbeddingProvider } from "../../types.js"; +import { getEnvVar } from "../../config.js"; +import { fetchWithTimeout } from "../_fetch.js"; + +const BATCH_LIMIT = 100; +const MODEL = "models/gemini-embedding-001"; +const API_BASE = `https://generativelanguage.googleapis.com/v1beta/${MODEL}:batchEmbedContents`; + +export class GeminiEmbeddingProvider implements EmbeddingProvider { + readonly name = "gemini"; + readonly dimensions = 768; + private apiKey: string; + + constructor(apiKey?: string) { + this.apiKey = apiKey || getEnvVar("GEMINI_API_KEY") || ""; + if (!this.apiKey) throw new Error("GEMINI_API_KEY is required"); + } + + async embed(text: string): Promise { + const [result] = await this.embedBatch([text]); + return result; + } + + async embedBatch(texts: string[]): Promise { + const results: Float32Array[] = []; + + for (let i = 0; i < texts.length; i += BATCH_LIMIT) { + const chunk = texts.slice(i, i + BATCH_LIMIT); + const response = await fetchWithTimeout(`${API_BASE}?key=${this.apiKey}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requests: chunk.map((t) => ({ + model: MODEL, + content: { parts: [{ text: t }] }, + outputDimensionality: this.dimensions, + })), + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Gemini embedding failed (${response.status}): ${err}`); + } + + const data = (await response.json()) as { + embeddings: Array<{ values: number[] }>; + }; + + for (const emb of data.embeddings) { + results.push(l2Normalize(new Float32Array(emb.values))); + } + } + + return results; + } +} + +let zeroNormWarned = false; + +function l2Normalize(vec: Float32Array): Float32Array { + let sum = 0; + for (let i = 0; i < vec.length; i++) sum += vec[i]! * vec[i]!; + const norm = Math.sqrt(sum); + if (norm === 0) { + if (!zeroNormWarned) { + zeroNormWarned = true; + process.stderr.write( + `[agentmemory] warn: gemini-embedding-001 returned a zero-norm ` + + `embedding (length=${vec.length}); leaving it un-normalized. ` + + `Subsequent zero-norm vectors will not be reported.\n`, + ); + } + return vec; + } + for (let i = 0; i < vec.length; i++) vec[i] = vec[i]! / norm; + return vec; +} diff --git a/src/providers/embedding/index.ts b/src/providers/embedding/index.ts new file mode 100644 index 0000000..d18de23 --- /dev/null +++ b/src/providers/embedding/index.ts @@ -0,0 +1,80 @@ +import type { EmbeddingProvider } from "../../types.js"; +import { detectEmbeddingProvider, getEnvVar } from "../../config.js"; +import { GeminiEmbeddingProvider } from "./gemini.js"; +import { OpenAIEmbeddingProvider } from "./openai.js"; +import { VoyageEmbeddingProvider } from "./voyage.js"; +import { CohereEmbeddingProvider } from "./cohere.js"; +import { OpenRouterEmbeddingProvider } from "./openrouter.js"; +import { LocalEmbeddingProvider } from "./local.js"; +import { ClipEmbeddingProvider } from "./clip.js"; + +export { + GeminiEmbeddingProvider, + OpenAIEmbeddingProvider, + VoyageEmbeddingProvider, + CohereEmbeddingProvider, + OpenRouterEmbeddingProvider, + LocalEmbeddingProvider, + ClipEmbeddingProvider, +}; + +let imageEmbeddingProvider: EmbeddingProvider | null = null; + +export function createImageEmbeddingProvider(): EmbeddingProvider | null { + if (process.env["AGENTMEMORY_IMAGE_EMBEDDINGS"] !== "true") return null; + if (imageEmbeddingProvider) return imageEmbeddingProvider; + imageEmbeddingProvider = withDimensionGuard(new ClipEmbeddingProvider()); + return imageEmbeddingProvider; +} + +export function createEmbeddingProvider(): EmbeddingProvider | null { + const detected = detectEmbeddingProvider(); + if (!detected) return null; + + switch (detected) { + case "gemini": + return withDimensionGuard(new GeminiEmbeddingProvider(getEnvVar("GEMINI_API_KEY")!)); + case "openai": + return withDimensionGuard(new OpenAIEmbeddingProvider(getEnvVar("OPENAI_API_KEY")!)); + case "voyage": + return withDimensionGuard(new VoyageEmbeddingProvider(getEnvVar("VOYAGE_API_KEY")!)); + case "cohere": + return withDimensionGuard(new CohereEmbeddingProvider(getEnvVar("COHERE_API_KEY")!)); + case "openrouter": + return withDimensionGuard(new OpenRouterEmbeddingProvider(getEnvVar("OPENROUTER_API_KEY")!)); + case "local": + return withDimensionGuard(new LocalEmbeddingProvider()); + default: + return null; + } +} + +// Wrong-dimension vectors corrupt the index silently: vector-index.ts +// returns 0 from cosineSimilarity on length mismatch instead of throwing, +// so a bad vector is stored, never matches anything, and the memory +// becomes invisible without an error. Catch it at the boundary. +export function withDimensionGuard(provider: EmbeddingProvider): EmbeddingProvider { + const expected = provider.dimensions; + const check = (v: Float32Array, where: string): Float32Array => { + if (v.length !== expected) { + throw new Error( + `Embedding dimension mismatch in ${provider.name}.${where}: expected ${expected}, got ${v.length}`, + ); + } + return v; + }; + // Preserve the provider's prototype chain so `instanceof` checks + // against concrete classes (e.g. GeminiEmbeddingProvider) keep working. + const wrapped = Object.create(provider) as EmbeddingProvider; + wrapped.embed = async (t) => check(await provider.embed(t), "embed"); + wrapped.embedBatch = async (ts) => { + const out = await provider.embedBatch(ts); + out.forEach((v, i) => check(v, `embedBatch[${i}]`)); + return out; + }; + if (provider.embedImage) { + wrapped.embedImage = async (s: string) => + check(await provider.embedImage!(s), "embedImage"); + } + return wrapped; +} diff --git a/src/providers/embedding/local.ts b/src/providers/embedding/local.ts new file mode 100644 index 0000000..ad6c2d2 --- /dev/null +++ b/src/providers/embedding/local.ts @@ -0,0 +1,52 @@ +import type { EmbeddingProvider } from "../../types.js"; + +type Pipeline = ( + task: string, + model: string, +) => Promise< + ( + texts: string[], + options: { pooling: string; normalize: boolean }, + ) => Promise<{ tolist: () => number[][] }> +>; + +export class LocalEmbeddingProvider implements EmbeddingProvider { + readonly name = "local"; + readonly dimensions = 384; + private extractor: Awaited> | null = null; + + async embed(text: string): Promise { + const [result] = await this.embedBatch([text]); + return result; + } + + async embedBatch(texts: string[]): Promise { + const extractor = await this.getExtractor(); + const output = await extractor(texts, { + pooling: "mean", + normalize: true, + }); + const vectors = output.tolist(); + return vectors.map((v: number[]) => new Float32Array(v)); + } + + private async getExtractor() { + if (this.extractor) return this.extractor; + + let transformers: { pipeline: Pipeline }; + try { + // @ts-ignore - optional peer dependency + transformers = await import("@xenova/transformers"); + } catch { + throw new Error( + "Install @xenova/transformers for local embeddings: npm install @xenova/transformers", + ); + } + + this.extractor = await transformers.pipeline( + "feature-extraction", + "Xenova/all-MiniLM-L6-v2", + ); + return this.extractor; + } +} diff --git a/src/providers/embedding/openai.ts b/src/providers/embedding/openai.ts new file mode 100644 index 0000000..7384d51 --- /dev/null +++ b/src/providers/embedding/openai.ts @@ -0,0 +1,147 @@ +import type { EmbeddingProvider } from "../../types.js"; +import { getEnvVar } from "../../config.js"; +import { fetchWithTimeout } from "../_fetch.js"; +import { + DEFAULT_AZURE_API_VERSION, + buildAuthHeaders, + buildEmbeddingUrl, + detectAzure, + normalizeBaseUrl, +} from "../_openai-shared.js"; + +const DEFAULT_MODEL = "text-embedding-3-small"; + +/** + * Known OpenAI embedding model dimensions. Extend as new models ship. + * Override in any case via OPENAI_EMBEDDING_DIMENSIONS for custom or + * self-hosted OpenAI-compatible endpoints returning non-standard sizes. + */ +const MODEL_DIMENSIONS: Record = { + "text-embedding-3-small": 1536, + "text-embedding-3-large": 3072, + "text-embedding-ada-002": 1536, +}; + +const DEFAULT_DIMENSIONS = MODEL_DIMENSIONS[DEFAULT_MODEL] ?? 1536; + +function resolveDimensions(model: string, override: string | undefined): number { + if (override !== undefined && override.trim().length > 0) { + const parsed = parseInt(override, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `OPENAI_EMBEDDING_DIMENSIONS must be a positive integer, got: ${override}`, + ); + } + return parsed; + } + return MODEL_DIMENSIONS[model] ?? DEFAULT_DIMENSIONS; +} + +/** + * OpenAI-compatible embedding provider. + * + * Shares transport (URL builder, auth header, Azure detection) with + * the OpenAI LLM provider via `_openai-shared` (#371). Same env knobs + * pick up automatically: when `OPENAI_BASE_URL` points at an Azure + * resource (`.openai.azure.com` hostname) the embedding request uses + * Azure's `/embeddings` path with the `api-version` query param and + * `api-key` header instead of `Authorization: Bearer`. + * + * Required env vars: + * OPENAI_API_KEY — API key (fallback for OPENAI_EMBEDDING_API_KEY) + * + * Optional: + * OPENAI_BASE_URL — base URL without path (default: https://api.openai.com). + * Azure: https://.openai.azure.com/openai/deployments/ + * OPENAI_EMBEDDING_BASE_URL — embedding-specific base URL override (defaults + * to OPENAI_BASE_URL). Lets operators run + * embeddings on a separate endpoint from chat — + * e.g. local Ollama / LM Studio / llama.cpp / + * vLLM at http://localhost:1234 for unlimited + * free embeddings, while keeping chat + * completions on a rate-limited but high-quality + * hosted provider. Azure detection runs on + * whichever URL ends up selected. + * OPENAI_EMBEDDING_API_KEY — separate API key for the embedding endpoint + * (defaults to OPENAI_API_KEY). Useful when the + * embedding endpoint requires a different key + * or no key at all (set to e.g. "local" for + * endpoints that ignore Authorization). + * OPENAI_API_VERSION — Azure api-version query param (default: 2024-08-01-preview) + * OPENAI_EMBEDDING_MODEL — model name (default: text-embedding-3-small) + * OPENAI_EMBEDDING_DIMENSIONS — override reported dimensions (required for + * custom / self-hosted models not in the + * MODEL_DIMENSIONS table above) + */ +export class OpenAIEmbeddingProvider implements EmbeddingProvider { + readonly name = "openai"; + readonly dimensions: number; + private apiKey: string; + private baseUrl: string; + private model: string; + private isAzure: boolean; + private azureApiVersion: string; + + constructor(apiKey?: string) { + // Separate API key path: caller-passed wins, then OPENAI_EMBEDDING_API_KEY, + // then fall back to OPENAI_API_KEY. Allows e.g. a placeholder key for + // local endpoints that ignore Authorization (most do). + this.apiKey = + apiKey || + getEnvVar("OPENAI_EMBEDDING_API_KEY") || + getEnvVar("OPENAI_API_KEY") || + ""; + if (!this.apiKey) { + throw new Error( + "API key is required (via constructor, OPENAI_EMBEDDING_API_KEY, or OPENAI_API_KEY)", + ); + } + // Embedding-specific base URL override; falls back to OPENAI_BASE_URL, + // then normalizeBaseUrl's default. The chat-LLM path (src/providers/openai.ts) + // still reads only OPENAI_BASE_URL, so setting OPENAI_EMBEDDING_BASE_URL + // alone moves embeddings to the new endpoint without affecting chat. + this.baseUrl = normalizeBaseUrl( + getEnvVar("OPENAI_EMBEDDING_BASE_URL") || getEnvVar("OPENAI_BASE_URL"), + ); + this.model = getEnvVar("OPENAI_EMBEDDING_MODEL") || DEFAULT_MODEL; + this.dimensions = resolveDimensions( + this.model, + getEnvVar("OPENAI_EMBEDDING_DIMENSIONS"), + ); + this.isAzure = detectAzure(this.baseUrl); + this.azureApiVersion = + getEnvVar("OPENAI_API_VERSION") || DEFAULT_AZURE_API_VERSION; + } + + async embed(text: string): Promise { + const [result] = await this.embedBatch([text]); + return result; + } + + async embedBatch(texts: string[]): Promise { + const url = buildEmbeddingUrl( + this.baseUrl, + this.isAzure, + this.azureApiVersion, + ); + const response = await fetchWithTimeout(url, { + method: "POST", + headers: buildAuthHeaders(this.apiKey, this.isAzure), + body: JSON.stringify({ + model: this.model, + input: texts, + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`OpenAI embedding failed (${response.status}): ${err}`); + } + + const data = (await response.json()) as { + data: Array<{ embedding: number[] }>; + }; + + return data.data.map((d) => new Float32Array(d.embedding)); + } +} diff --git a/src/providers/embedding/openrouter.ts b/src/providers/embedding/openrouter.ts new file mode 100644 index 0000000..46999e5 --- /dev/null +++ b/src/providers/embedding/openrouter.ts @@ -0,0 +1,52 @@ +import type { EmbeddingProvider } from "../../types.js"; +import { getEnvVar } from "../../config.js"; +import { fetchWithTimeout } from "../_fetch.js"; + +const API_URL = "https://openrouter.ai/api/v1/embeddings"; + +export class OpenRouterEmbeddingProvider implements EmbeddingProvider { + readonly name = "openrouter"; + readonly dimensions = 1536; + private apiKey: string; + private model: string; + + constructor(apiKey?: string) { + this.apiKey = apiKey || getEnvVar("OPENROUTER_API_KEY") || ""; + if (!this.apiKey) throw new Error("OPENROUTER_API_KEY is required"); + this.model = + getEnvVar("OPENROUTER_EMBEDDING_MODEL") || + "openai/text-embedding-3-small"; + } + + async embed(text: string): Promise { + const [result] = await this.embedBatch([text]); + return result; + } + + async embedBatch(texts: string[]): Promise { + const response = await fetchWithTimeout(API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.model, + input: texts, + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error( + `OpenRouter embedding failed (${response.status}): ${err}`, + ); + } + + const data = (await response.json()) as { + data: Array<{ embedding: number[] }>; + }; + + return data.data.map((d) => new Float32Array(d.embedding)); + } +} diff --git a/src/providers/embedding/voyage.ts b/src/providers/embedding/voyage.ts new file mode 100644 index 0000000..14ddc36 --- /dev/null +++ b/src/providers/embedding/voyage.ts @@ -0,0 +1,47 @@ +import type { EmbeddingProvider } from "../../types.js"; +import { getEnvVar } from "../../config.js"; +import { fetchWithTimeout } from "../_fetch.js"; + +const API_URL = "https://api.voyageai.com/v1/embeddings"; + +export class VoyageEmbeddingProvider implements EmbeddingProvider { + readonly name = "voyage"; + readonly dimensions = 1024; + private apiKey: string; + + constructor(apiKey?: string) { + this.apiKey = apiKey || getEnvVar("VOYAGE_API_KEY") || ""; + if (!this.apiKey) throw new Error("VOYAGE_API_KEY is required"); + } + + async embed(text: string): Promise { + const [result] = await this.embedBatch([text]); + return result; + } + + async embedBatch(texts: string[]): Promise { + const response = await fetchWithTimeout(API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "voyage-code-3", + input: texts, + input_type: "document", + }), + }); + + if (!response.ok) { + const err = await response.text(); + throw new Error(`Voyage embedding failed (${response.status}): ${err}`); + } + + const data = (await response.json()) as { + data: Array<{ embedding: number[] }>; + }; + + return data.data.map((d) => new Float32Array(d.embedding)); + } +} diff --git a/src/providers/fallback-chain.ts b/src/providers/fallback-chain.ts new file mode 100644 index 0000000..e2c979f --- /dev/null +++ b/src/providers/fallback-chain.ts @@ -0,0 +1,31 @@ +import type { MemoryProvider } from "../types.js"; + +export class FallbackChainProvider implements MemoryProvider { + name: string; + + constructor(private providers: MemoryProvider[]) { + this.name = `fallback(${providers.map((p) => p.name).join(" -> ")})`; + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.tryAll((p) => p.compress(systemPrompt, userPrompt)); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.tryAll((p) => p.summarize(systemPrompt, userPrompt)); + } + + private async tryAll( + fn: (p: MemoryProvider) => Promise, + ): Promise { + let lastError: Error | null = null; + for (const provider of this.providers) { + try { + return await fn(provider); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + } + } + throw lastError || new Error("No providers available"); + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..0ec3feb --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,152 @@ +import type { + MemoryProvider, + ProviderConfig, + FallbackConfig, +} from "../types.js"; +import { AgentSDKProvider } from "./agent-sdk.js"; +import { AnthropicProvider } from "./anthropic.js"; +import { MinimaxProvider } from "./minimax.js"; +import { NoopProvider } from "./noop.js"; +import { OpenAIProvider } from "./openai.js"; +import { OpenRouterProvider } from "./openrouter.js"; +import { ResilientProvider } from "./resilient.js"; +import { FallbackChainProvider } from "./fallback-chain.js"; +import { getEnvVar } from "../config.js"; + +export { createEmbeddingProvider, createImageEmbeddingProvider } from "./embedding/index.js"; + +function requireEnvVar(key: string): string { + const value = getEnvVar(key); + if (!value) { + throw new Error( + `Missing required environment variable: ${key}. Set it in ~/.agentmemory/.env or as an environment variable.`, + ); + } + return value; +} + +// #778: fallback providers used to inherit the primary provider's +// model name (e.g. fallback Gemini was called with `gpt-4o-mini`), +// 404'd every call, and tripped the circuit breaker — making +// FALLBACK_PROVIDERS actively worse than no fallback. Each provider +// must resolve its OWN env-driven default model. Mirrors the resolution +// in detectProvider() so primary + fallback agree on what each +// provider's default model is. +function defaultModelFor(providerType: ProviderConfig["provider"]): string { + switch (providerType) { + case "openai": + return getEnvVar("OPENAI_MODEL") || "gpt-4o-mini"; + case "anthropic": + return getEnvVar("ANTHROPIC_MODEL") || "claude-sonnet-4-20250514"; + case "gemini": + return getEnvVar("GEMINI_MODEL") || "gemini-2.5-flash"; + case "openrouter": + return ( + getEnvVar("OPENROUTER_MODEL") || "anthropic/claude-sonnet-4-20250514" + ); + case "minimax": + return getEnvVar("MINIMAX_MODEL") || "MiniMax-M2.7"; + case "agent-sdk": + return "claude-sonnet-4-20250514"; + case "noop": + default: + return "noop"; + } +} + +export function createProvider(config: ProviderConfig): ResilientProvider { + return new ResilientProvider(createBaseProvider(config)); +} + +export function createFallbackProvider( + config: ProviderConfig, + fallbackConfig: FallbackConfig, +): ResilientProvider { + if (fallbackConfig.providers.length === 0) { + return createProvider(config); + } + + const providers: MemoryProvider[] = [createBaseProvider(config)]; + for (const providerType of fallbackConfig.providers) { + if (providerType === config.provider) continue; + try { + // #778: resolve the fallback's OWN default model (or its env + // override) rather than copying config.model from the primary. + // Without this, FALLBACK_PROVIDERS=gemini on an OpenAI primary + // would call Gemini with `gpt-4o-mini`, get a 404 every time, + // and trip the circuit breaker. + const fbConfig: ProviderConfig = { + provider: providerType, + model: defaultModelFor(providerType), + maxTokens: config.maxTokens, + }; + providers.push(createBaseProvider(fbConfig)); + } catch { + // skip unavailable fallback providers + } + } + + if (providers.length > 1) { + return new ResilientProvider(new FallbackChainProvider(providers)); + } + return new ResilientProvider(providers[0]); +} + +function createBaseProvider(config: ProviderConfig): MemoryProvider { + switch (config.provider) { + case "minimax": + return new MinimaxProvider( + requireEnvVar("MINIMAX_API_KEY"), + config.model, + config.maxTokens, + ); + case "anthropic": + return new AnthropicProvider( + requireEnvVar("ANTHROPIC_API_KEY"), + config.model, + config.maxTokens, + config.baseURL, + ); + case "gemini": { + const geminiKey = + getEnvVar("GEMINI_API_KEY") || getEnvVar("GOOGLE_API_KEY"); + if (!geminiKey) { + throw new Error( + "GEMINI_API_KEY (or GOOGLE_API_KEY) is required for the gemini provider", + ); + } + return new OpenRouterProvider( + geminiKey, + config.model, + config.maxTokens, + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + ); + } + case "openrouter": + return new OpenRouterProvider( + requireEnvVar("OPENROUTER_API_KEY"), + config.model, + config.maxTokens, + "https://openrouter.ai/api/v1/chat/completions", + ); + case "openai": { + const openaiKey = getEnvVar("OPENAI_API_KEY"); + if (!openaiKey) { + throw new Error( + "OPENAI_API_KEY is required for the openai provider", + ); + } + return new OpenAIProvider( + openaiKey, + config.model, + config.maxTokens, + config.baseURL, + ); + } + case "noop": + return new NoopProvider(); + case "agent-sdk": + default: + return new AgentSDKProvider(); + } +} diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts new file mode 100644 index 0000000..72fc9ec --- /dev/null +++ b/src/providers/minimax.ts @@ -0,0 +1,70 @@ +import type { MemoryProvider } from '../types.js' +import { getEnvVar } from '../config.js' +import { fetchWithTimeout } from './_fetch.js' + +/** + * MiniMax provider using raw fetch to call MiniMax's Anthropic-compatible API. + * + * The Anthropic SDK automatically injects `x-stainless-*` headers that MiniMax + * rejects with 403. This provider bypasses the SDK and calls the API directly. + * + * 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) + * + * Optional: + * MINIMAX_BASE_URL — base URL without path (default: https://api.minimax.io/anthropic) + */ +export class MinimaxProvider implements MemoryProvider { + name = 'minimax' + private apiKey: string + private model: string + private maxTokens: number + private baseUrl: string + + constructor(apiKey: string, model: string, maxTokens: number) { + this.apiKey = apiKey + this.model = model + this.maxTokens = maxTokens + this.baseUrl = + getEnvVar('MINIMAX_BASE_URL') || 'https://api.minimax.io/anthropic' + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt) + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt) + } + + private async call(systemPrompt: string, userPrompt: string): Promise { + const url = `${this.baseUrl}/v1/messages` + const response = await fetchWithTimeout(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: this.model, + max_tokens: this.maxTokens, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }), + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`MiniMax API error ${response.status}: ${text}`) + } + + const data = (await response.json()) as { + content?: Array<{ type: string; text?: string }> + } + const textBlock = data.content?.find((b) => b.type === 'text') + return textBlock?.text ?? '' + } +} diff --git a/src/providers/noop.ts b/src/providers/noop.ts new file mode 100644 index 0000000..6e74f9a --- /dev/null +++ b/src/providers/noop.ts @@ -0,0 +1,20 @@ +import type { MemoryProvider } from "../types.js"; + +/** + * Returns empty strings for every call. Used when no LLM API key is set + * AND the user has not opted into the agent-sdk fallback via + * AGENTMEMORY_ALLOW_AGENT_SDK=true. Callers (compress, summarize) must + * detect the empty result and short-circuit instead of spawning a + * provider session (#149 / Stop-hook recursion loop fix). + */ +export class NoopProvider implements MemoryProvider { + name = "noop"; + + async compress(): Promise { + return ""; + } + + async summarize(): Promise { + return ""; + } +} diff --git a/src/providers/openai.ts b/src/providers/openai.ts new file mode 100644 index 0000000..438b2f4 --- /dev/null +++ b/src/providers/openai.ts @@ -0,0 +1,182 @@ +import type { MemoryProvider } from "../types.js"; +import { getEnvVar } from "../config.js"; +import { fetchWithTimeout } from "./_fetch.js"; +import { + DEFAULT_AZURE_API_VERSION, + buildAuthHeaders, + buildChatUrl, + detectAzure, + normalizeBaseUrl, +} from "./_openai-shared.js"; + +const DEFAULT_MODEL = "gpt-4o-mini"; +const DEFAULT_TIMEOUT_MS = 60_000; + +/** + * OpenAI-compatible LLM provider. + * + * Uses raw fetch (no SDK) to support any OpenAI-compatible endpoint: + * - OpenAI official + * - Azure OpenAI (auto-detected from .openai.azure.com host) + * - DeepSeek + * - 硅基流动 (SiliconFlow) + * - vLLM / LM Studio / Ollama (with OpenAI compatibility layer) + * - Any other proxy implementing /v1/chat/completions + * + * Required env vars: + * OPENAI_API_KEY — API key + * + * 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_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 + * for back-compat with the v0.9.17 shipping name). + * AGENTMEMORY_LLM_TIMEOUT_MS — outbound fetch timeout in ms shared across all + * raw-fetch LLM + embedding providers. Used when + * OPENAI_TIMEOUT_MS is not set. Default: 60000. + * MAX_TOKENS — max output tokens (default: from config or 4096) + * OPENAI_REASONING_EFFORT — "low" | "medium" | "high" | "none" + * Passthrough for reasoning models (e.g. Ollama Cloud + * thinking models). Set to "none" to ensure + * message.content is populated instead of only + * message.reasoning. + */ +export class OpenAIProvider implements MemoryProvider { + name = "openai"; + private apiKey: string; + private model: string; + private maxTokens: number; + private baseUrl: string; + private reasoningEffort?: string; + private timeoutMs: number; + private isAzure: boolean; + private azureApiVersion: string; + + constructor(apiKey: string, model: string, maxTokens: number, baseURL?: string) { + this.apiKey = apiKey; + this.model = model; + this.maxTokens = maxTokens; + this.baseUrl = normalizeBaseUrl(baseURL || getEnvVar("OPENAI_BASE_URL")); + this.reasoningEffort = getEnvVar("OPENAI_REASONING_EFFORT") || undefined; + this.timeoutMs = resolveTimeout(); + this.azureApiVersion = + getEnvVar("OPENAI_API_VERSION") || DEFAULT_AZURE_API_VERSION; + this.isAzure = detectAzure(this.baseUrl); + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + private async call(systemPrompt: string, userPrompt: string): Promise { + const url = buildChatUrl(this.baseUrl, this.isAzure, this.azureApiVersion); + const body: Record = { + model: this.model, + max_tokens: this.maxTokens, + // OpenAI API spec defines `stream` as defaulting to false, so omitting + // it should yield a JSON response. Some OpenAI-compatible proxies + // (notably 9Router < 0.4.56 — see decolua/9router#1260) default to + // text/event-stream when `stream` is absent, which crashes the + // `response.json()` call below with `Unexpected token 'd', "data: {"id"...`. + // Send it explicitly so non-spec endpoints route to non-streaming too. + stream: false, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }; + if (this.reasoningEffort) { + body.reasoning_effort = this.reasoningEffort; + } + + // Bound the request via the shared fetchWithTimeout helper, which + // owns the AbortController + clearTimeout cleanup for every raw-fetch + // provider (minimax, openrouter, gemini, openrouter-embed, etc.). + // OPENAI_TIMEOUT_MS keeps its v0.9.17 meaning (OpenAI-scoped alias, + // takes precedence); when unset we fall through to + // AGENTMEMORY_LLM_TIMEOUT_MS and finally the 60s default. See #446. + let response: Response; + try { + response = await fetchWithTimeout( + url, + { + method: "POST", + headers: buildAuthHeaders(this.apiKey, this.isAzure), + body: JSON.stringify(body), + }, + this.timeoutMs, + ); + } catch (err) { + const aborted = err instanceof Error && err.name === "AbortError"; + if (aborted) { + throw new Error( + `OpenAI API request timed out after ${this.timeoutMs}ms — set OPENAI_TIMEOUT_MS (or AGENTMEMORY_LLM_TIMEOUT_MS) to raise the bound or check the provider status.`, + ); + } + throw err; + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenAI API error (${response.status}): ${text}`); + } + + const data = (await response.json()) as { + choices?: Array<{ + message?: { content?: string; reasoning?: string; reasoning_content?: string }; + }>; + }; + const message = data.choices?.[0]?.message; + const content = message?.content; + if (content) { + return content; + } + // Fallback: some thinking models return reasoning but no content. + // DeepSeek V4 / Qwen3 / GLM / Kimi return `reasoning_content`; + // older OpenAI o-series + some compatibles return `reasoning`. #627 + const reasoning = message?.reasoning ?? message?.reasoning_content; + if (reasoning) { + return reasoning; + } + throw new Error( + `OpenAI returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, + ); + } +} + +// Resolves the outbound-fetch timeout for the OpenAI LLM path. +// Precedence (preserving v0.9.17 behaviour): +// 1. OPENAI_TIMEOUT_MS — OpenAI-scoped alias (back-compat) +// 2. AGENTMEMORY_LLM_TIMEOUT_MS — global LLM/embedding timeout (#446) +// 3. 60 000 ms default +function resolveTimeout(): number { + const openaiRaw = getEnvVar("OPENAI_TIMEOUT_MS"); + const openai = parsePositiveInt(openaiRaw); + if (openai !== undefined) return openai; + + const globalRaw = getEnvVar("AGENTMEMORY_LLM_TIMEOUT_MS"); + const globalMs = parsePositiveInt(globalRaw); + if (globalMs !== undefined) return globalMs; + + return DEFAULT_TIMEOUT_MS; +} + +function parsePositiveInt(raw: string | null | undefined): number | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + // Reject malformed values like "30ms" or "1_000" — parseInt would + // silently return 30 / 1, swallowing user typos as valid timeouts. + // The regex enforces pure digits (no sign, no trailing units, no + // separators) before we hand off to Number. + if (!/^\d+$/.test(trimmed)) return undefined; + const n = Number(trimmed); + return Number.isFinite(n) && n > 0 ? n : undefined; +} + diff --git a/src/providers/openrouter.ts b/src/providers/openrouter.ts new file mode 100644 index 0000000..5c47bb0 --- /dev/null +++ b/src/providers/openrouter.ts @@ -0,0 +1,72 @@ +import type { MemoryProvider } from "../types.js"; +import { fetchWithTimeout } from "./_fetch.js"; + +export class OpenRouterProvider implements MemoryProvider { + name: string; + private apiKey: string; + private model: string; + private maxTokens: number; + private baseUrl: string; + + constructor( + apiKey: string, + model: string, + maxTokens: number, + baseUrl: string, + ) { + this.apiKey = apiKey; + this.model = model; + this.maxTokens = maxTokens; + this.baseUrl = baseUrl; + this.name = baseUrl.includes("openrouter") ? "openrouter" : "gemini"; + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.call(systemPrompt, userPrompt); + } + + private async call( + systemPrompt: string, + userPrompt: string, + ): Promise { + const response = await fetchWithTimeout(this.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + ...(this.baseUrl.includes("openrouter") + ? { "HTTP-Referer": "https://github.com/rohitg00/agentmemory" } + : {}), + }, + body: JSON.stringify({ + model: this.model, + max_tokens: this.maxTokens, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${this.name} API error (${response.status}): ${text}`); + } + + const data = (await response.json()) as Record; + const choices = data.choices as + | Array<{ message: { content: string } }> + | undefined; + const content = choices?.[0]?.message?.content; + if (!content) { + throw new Error( + `${this.name} returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`, + ); + } + return content; + } +} diff --git a/src/providers/resilient.ts b/src/providers/resilient.ts new file mode 100644 index 0000000..95ece40 --- /dev/null +++ b/src/providers/resilient.ts @@ -0,0 +1,37 @@ +import type { MemoryProvider, CircuitBreakerState } from "../types.js"; +import { CircuitBreaker } from "./circuit-breaker.js"; + +export class ResilientProvider implements MemoryProvider { + private breaker = new CircuitBreaker(); + name: string; + + constructor(private inner: MemoryProvider) { + this.name = `resilient(${inner.name})`; + } + + private async call(fn: () => Promise): Promise { + if (!this.breaker.isAllowed) { + throw new Error("circuit_breaker_open"); + } + try { + const result = await fn(); + this.breaker.recordSuccess(); + return result; + } catch (err) { + this.breaker.recordFailure(); + throw err; + } + } + + async compress(systemPrompt: string, userPrompt: string): Promise { + return this.call(() => this.inner.compress(systemPrompt, userPrompt)); + } + + async summarize(systemPrompt: string, userPrompt: string): Promise { + return this.call(() => this.inner.summarize(systemPrompt, userPrompt)); + } + + get circuitState(): CircuitBreakerState { + return this.breaker.getState(); + } +} diff --git a/src/replay/jsonl-parser.ts b/src/replay/jsonl-parser.ts new file mode 100644 index 0000000..5060c34 --- /dev/null +++ b/src/replay/jsonl-parser.ts @@ -0,0 +1,181 @@ +import type { HookType, RawObservation } from "../types.js"; +import { generateId } from "../state/schema.js"; + +interface JsonlEntry { + type?: string; + uuid?: string; + sessionId?: string; + timestamp?: string; + cwd?: string; + message?: { + role?: string; + content?: unknown; + }; + toolUseResult?: unknown; + [k: string]: unknown; +} + +export interface ParsedTranscript { + sessionId: string; + project: string; + cwd: string; + startedAt: string; + endedAt: string; + observations: RawObservation[]; +} + +function deriveProject(cwd: string): string { + if (!cwd) return "unknown"; + const parts = cwd.split("/").filter(Boolean); + return parts[parts.length - 1] || "unknown"; +} + +function toText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const item of content) { + if (!item || typeof item !== "object") continue; + const entry = item as Record; + if (entry.type === "text" && typeof entry.text === "string") { + parts.push(entry.text); + } + } + return parts.join("\n"); +} + +function extractToolUses(content: unknown): Array<{ id: string; name: string; input: unknown }> { + if (!Array.isArray(content)) return []; + const out: Array<{ id: string; name: string; input: unknown }> = []; + for (const item of content) { + if (!item || typeof item !== "object") continue; + const entry = item as Record; + if (entry.type === "tool_use") { + out.push({ + id: typeof entry.id === "string" ? entry.id : "", + name: typeof entry.name === "string" ? entry.name : "unknown", + input: entry.input, + }); + } + } + return out; +} + +function extractToolResults(content: unknown): Array<{ toolUseId: string; output: unknown; isError: boolean }> { + if (!Array.isArray(content)) return []; + const out: Array<{ toolUseId: string; output: unknown; isError: boolean }> = []; + for (const item of content) { + if (!item || typeof item !== "object") continue; + const entry = item as Record; + if (entry.type === "tool_result") { + out.push({ + toolUseId: typeof entry.tool_use_id === "string" ? entry.tool_use_id : "", + output: entry.content, + isError: entry.is_error === true, + }); + } + } + return out; +} + +export function parseJsonlText(text: string, fallbackSessionId?: string): ParsedTranscript { + const lines = text.split("\n").filter((l) => l.trim().length > 0); + const entries: JsonlEntry[] = []; + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed && typeof parsed === "object") entries.push(parsed as JsonlEntry); + } catch { + // skip malformed lines + } + } + + let sessionId = ""; + let cwd = ""; + let firstTs = ""; + let lastTs = ""; + + const observations: RawObservation[] = []; + + for (const entry of entries) { + if (entry.sessionId && !sessionId) sessionId = entry.sessionId; + if (entry.cwd && !cwd) cwd = entry.cwd; + const ts = entry.timestamp || new Date().toISOString(); + if (!firstTs) firstTs = ts; + lastTs = ts; + + const role = entry.message?.role; + const content = entry.message?.content; + + if (entry.type === "user" && role === "user") { + const toolResults = extractToolResults(content); + if (toolResults.length > 0) { + for (const result of toolResults) { + observations.push({ + id: generateId("obs"), + sessionId: sessionId || "imported", + timestamp: ts, + hookType: (result.isError ? "post_tool_failure" : "post_tool_use") as HookType, + toolName: undefined, + toolInput: { toolUseId: result.toolUseId }, + toolOutput: result.output, + raw: entry, + }); + } + } else { + const text = toText(content); + if (text.trim().length > 0) { + observations.push({ + id: generateId("obs"), + sessionId: sessionId || "imported", + timestamp: ts, + hookType: "prompt_submit" as HookType, + userPrompt: text, + raw: entry, + }); + } + } + } else if (entry.type === "assistant" && role === "assistant") { + const text = toText(content); + const tools = extractToolUses(content); + if (text.trim().length > 0) { + observations.push({ + id: generateId("obs"), + sessionId: sessionId || "imported", + timestamp: ts, + hookType: "stop" as HookType, + assistantResponse: text, + raw: entry, + }); + } + for (const tool of tools) { + observations.push({ + id: generateId("obs"), + sessionId: sessionId || "imported", + timestamp: ts, + hookType: "pre_tool_use" as HookType, + toolName: tool.name, + toolInput: tool.input, + raw: { toolUseId: tool.id, entry }, + }); + } + } else if (entry.type === "summary" || entry.type === "system") { + // ignore meta entries + } + } + + const effectiveSessionId = sessionId || fallbackSessionId || generateId("sess"); + for (const obs of observations) { + if (obs.sessionId === "imported") obs.sessionId = effectiveSessionId; + } + + const nowIso = new Date().toISOString(); + return { + sessionId: effectiveSessionId, + project: deriveProject(cwd), + cwd: cwd || process.cwd(), + startedAt: firstTs || nowIso, + endedAt: lastTs || nowIso, + observations, + }; +} diff --git a/src/replay/timeline.ts b/src/replay/timeline.ts new file mode 100644 index 0000000..03e8051 --- /dev/null +++ b/src/replay/timeline.ts @@ -0,0 +1,166 @@ +import type { RawObservation } from "../types.js"; + +export type TimelineEventKind = + | "prompt" + | "response" + | "tool_call" + | "tool_result" + | "tool_error" + | "hook" + | "session_start" + | "session_end"; + +export interface TimelineEvent { + id: string; + sessionId: string; + ts: string; + offsetMs: number; + durationMs: number; + kind: TimelineEventKind; + label: string; + body?: string; + toolName?: string; + toolInput?: unknown; + toolOutput?: unknown; +} + +export interface Timeline { + sessionId: string; + startedAt: string; + endedAt: string; + totalDurationMs: number; + eventCount: number; + events: TimelineEvent[]; +} + +const DEFAULT_CHARS_PER_SEC = 40; +const MIN_EVENT_MS = 300; +const MAX_EVENT_MS = 20_000; + +function kindFromHook(obs: RawObservation): TimelineEventKind { + switch (obs.hookType) { + case "session_start": + return "session_start"; + case "session_end": + return "session_end"; + case "prompt_submit": + return "prompt"; + case "stop": + return obs.assistantResponse ? "response" : "hook"; + case "pre_tool_use": + return "tool_call"; + case "post_tool_use": + return "tool_result"; + case "post_tool_failure": + return "tool_error"; + default: + return "hook"; + } +} + +function labelFor(obs: RawObservation, kind: TimelineEventKind): string { + switch (kind) { + case "prompt": + return truncate(obs.userPrompt || "User prompt", 80); + case "response": + return truncate(obs.assistantResponse || "Assistant response", 80); + case "tool_call": + return `${obs.toolName || "tool"} ▸ call`; + case "tool_result": + return `${obs.toolName || "tool"} ▸ result`; + case "tool_error": + return `${obs.toolName || "tool"} ▸ error`; + case "session_start": + return "Session start"; + case "session_end": + return "Session end"; + default: + return obs.hookType; + } +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, max - 1) + "…"; +} + +function bodyFor(obs: RawObservation, kind: TimelineEventKind): string | undefined { + if (kind === "prompt") return obs.userPrompt; + if (kind === "response") return obs.assistantResponse; + return undefined; +} + +function estimateDurationMs(ev: TimelineEvent): number { + const chars = + (ev.body?.length || 0) + + (typeof ev.toolInput === "string" ? ev.toolInput.length : 0) + + (typeof ev.toolOutput === "string" ? ev.toolOutput.length : 0); + if (chars === 0) return MIN_EVENT_MS; + const ms = Math.round((chars / DEFAULT_CHARS_PER_SEC) * 1000); + return Math.max(MIN_EVENT_MS, Math.min(MAX_EVENT_MS, ms)); +} + +export function projectTimeline(observations: RawObservation[]): Timeline { + if (observations.length === 0) { + const now = new Date().toISOString(); + return { + sessionId: "", + startedAt: now, + endedAt: now, + totalDurationMs: 0, + eventCount: 0, + events: [], + }; + } + + const sorted = [...observations].sort((a, b) => + a.timestamp.localeCompare(b.timestamp), + ); + + const startedAt = sorted[0].timestamp; + const startMs = Date.parse(startedAt); + const events: TimelineEvent[] = []; + + let syntheticOffset = 0; + const allSameTs = sorted.every((o) => o.timestamp === startedAt); + + for (const obs of sorted) { + const kind = kindFromHook(obs); + const body = bodyFor(obs, kind); + const obsMs = Date.parse(obs.timestamp); + const offsetMs = allSameTs + ? syntheticOffset + : Number.isFinite(obsMs) && Number.isFinite(startMs) + ? Math.max(0, obsMs - startMs) + : syntheticOffset; + + const event: TimelineEvent = { + id: obs.id, + sessionId: obs.sessionId, + ts: obs.timestamp, + offsetMs, + durationMs: 0, + kind, + label: labelFor(obs, kind), + body, + toolName: obs.toolName, + toolInput: obs.toolInput, + toolOutput: obs.toolOutput, + }; + event.durationMs = estimateDurationMs(event); + events.push(event); + syntheticOffset += event.durationMs; + } + + const last = events[events.length - 1]; + const totalDurationMs = last.offsetMs + last.durationMs; + + return { + sessionId: sorted[0].sessionId, + startedAt, + endedAt: sorted[sorted.length - 1].timestamp, + totalDurationMs, + eventCount: events.length, + events, + }; +} diff --git a/src/state/cjk-segmenter.ts b/src/state/cjk-segmenter.ts new file mode 100644 index 0000000..d6d4a19 --- /dev/null +++ b/src/state/cjk-segmenter.ts @@ -0,0 +1,169 @@ +import { createRequire } from "node:module"; + +const cjkRequire = createRequire(import.meta.url); + +const CJK_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; +const HAN_RE = /\p{Script=Han}/u; +const KANA_RE = /[\p{Script=Hiragana}\p{Script=Katakana}]/u; +const HANGUL_RE = /\p{Script=Hangul}/u; +const CJK_RUN_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu; +const HANGUL_BLOCK_RE = /[가-힯]+/g; + +type Script = "han" | "kana" | "hangul" | "other"; + +const hintShown = new Set(); + +export function hasCjk(text: string): boolean { + return CJK_RE.test(text); +} + +export function detectScript(text: string): Script { + if (HAN_RE.test(text)) return "han"; + if (KANA_RE.test(text)) return "kana"; + if (HANGUL_RE.test(text)) return "hangul"; + return "other"; +} + +function showHintOnce(key: string, message: string): void { + if (hintShown.has(key)) return; + hintShown.add(key); + if (typeof process !== "undefined" && process.stderr?.write) { + process.stderr.write(`agentmemory: ${message}\n`); + } +} + +interface JiebaInstance { + cut(text: string, hmm?: boolean): string[]; +} + +let jiebaInstance: JiebaInstance | null = null; +let jiebaLoaded = false; + +function getJieba(): JiebaInstance | null { + if (jiebaLoaded) return jiebaInstance; + jiebaLoaded = true; + try { + const mod = cjkRequire("@node-rs/jieba") as { + Jieba: { + new (): JiebaInstance; + withDict(dict: Uint8Array): JiebaInstance; + }; + }; + try { + const dictMod = cjkRequire("@node-rs/jieba/dict") as { dict: Uint8Array }; + jiebaInstance = mod.Jieba.withDict(dictMod.dict); + } catch { + jiebaInstance = new mod.Jieba(); + } + return jiebaInstance; + } catch { + showHintOnce( + "jieba", + "install @node-rs/jieba to improve Chinese search; falling back to whole-string tokenization", + ); + return null; + } +} + +interface JaSegmenter { + segment(text: string): string[]; +} + +let jaSegmenterInstance: JaSegmenter | null = null; +let jaSegmenterLoaded = false; + +function getJaSegmenter(): JaSegmenter | null { + if (jaSegmenterLoaded) return jaSegmenterInstance; + jaSegmenterLoaded = true; + try { + const Ctor = cjkRequire("tiny-segmenter") as new () => JaSegmenter; + jaSegmenterInstance = new Ctor(); + return jaSegmenterInstance; + } catch { + showHintOnce( + "tiny-segmenter", + "install tiny-segmenter to improve Japanese search; falling back to whole-string tokenization", + ); + return null; + } +} + +function cleanTokens(tokens: string[]): string[] { + const out: string[] = []; + for (const t of tokens) { + const trimmed = t.trim(); + if (trimmed) out.push(trimmed); + } + return out; +} + +function segmentHan(text: string): string[] { + const j = getJieba(); + if (!j) return [text]; + try { + return cleanTokens(j.cut(text, true)); + } catch { + return [text]; + } +} + +function segmentKana(text: string): string[] { + const s = getJaSegmenter(); + if (!s) return [text]; + try { + return cleanTokens(s.segment(text)); + } catch { + return [text]; + } +} + +function segmentHangul(text: string): string[] { + const out: string[] = []; + for (const m of text.matchAll(HANGUL_BLOCK_RE)) { + if (m[0]) out.push(m[0]); + } + return out; +} + +export function segmentCjk(text: string): string[] { + if (!hasCjk(text)) return [text]; + + const out: string[] = []; + let cursor = 0; + + for (const m of text.matchAll(CJK_RUN_RE)) { + const start = m.index ?? 0; + const run = m[0]; + const end = start + run.length; + + if (start > cursor) { + const piece = text.slice(cursor, start).trim(); + if (piece) out.push(piece); + } + + if (HANGUL_RE.test(run)) { + out.push(...segmentHangul(run)); + } else if (KANA_RE.test(run)) { + out.push(...segmentKana(run)); + } else { + out.push(...segmentHan(run)); + } + + cursor = end; + } + + if (cursor < text.length) { + const trailing = text.slice(cursor).trim(); + if (trailing) out.push(trailing); + } + + return out; +} + +export function __resetCjkSegmenterStateForTests(): void { + hintShown.clear(); + jiebaInstance = null; + jiebaLoaded = false; + jaSegmenterInstance = null; + jaSegmenterLoaded = false; +} diff --git a/src/state/hybrid-search.ts b/src/state/hybrid-search.ts new file mode 100644 index 0000000..d234a3e --- /dev/null +++ b/src/state/hybrid-search.ts @@ -0,0 +1,324 @@ +import { SearchIndex } from "./search-index.js"; +import { VectorIndex } from "./vector-index.js"; +import type { + EmbeddingProvider, + HybridSearchResult, + CompressedObservation, + Memory, + QueryExpansion, +} from "../types.js"; +import { memoryToObservation } from "./memory-utils.js"; +import type { StateKV } from "./kv.js"; +import { KV } from "./schema.js"; +import { + GraphRetrieval, + type GraphRetrievalResult, +} from "../functions/graph-retrieval.js"; +import { extractEntitiesFromQuery } from "../functions/query-expansion.js"; +import { rerank } from "./reranker.js"; + +const RRF_K = 60; + +export class HybridSearch { + private graphRetrieval: GraphRetrieval; + + constructor( + private bm25: SearchIndex, + private vector: VectorIndex | null, + private embeddingProvider: EmbeddingProvider | null, + private kv: StateKV, + private bm25Weight = 0.4, + private vectorWeight = 0.6, + private graphWeight = 0.3, + private rerankEnabled = process.env.RERANK_ENABLED === "true", + ) { + this.graphRetrieval = new GraphRetrieval(kv); + } + + async search(query: string, limit = 20): Promise { + return this.tripleStreamSearch(query, limit); + } + + async searchWithExpansion( + query: string, + limit: number, + expansion: QueryExpansion, + ): Promise { + const allQueries = [ + query, + ...expansion.reformulations, + ...expansion.temporalConcretizations, + ]; + + const allEntities = [ + ...expansion.entityExtractions, + ...extractEntitiesFromQuery(query), + ]; + + const resultSets = await Promise.all( + allQueries.map((q) => this.tripleStreamSearch(q, limit, allEntities)), + ); + + const merged = new Map(); + for (const results of resultSets) { + for (const r of results) { + const existing = merged.get(r.observation.id); + if (!existing || r.combinedScore > existing.combinedScore) { + merged.set(r.observation.id, r); + } + } + } + + return Array.from(merged.values()) + .sort((a, b) => b.combinedScore - a.combinedScore) + .slice(0, limit); + } + + private async tripleStreamSearch( + query: string, + limit: number, + entityHints?: string[], + ): Promise { + const bm25Results = this.bm25.search(query, limit * 2); + + let vectorResults: Array<{ + obsId: string; + sessionId: string; + score: number; + }> = []; + let queryEmbedding: Float32Array | null = null; + + if (this.vector && this.embeddingProvider && this.vector.size > 0) { + try { + queryEmbedding = await this.embeddingProvider.embed(query); + vectorResults = this.vector.search(queryEmbedding, limit * 2); + } catch { + // fall through to BM25-only + } + } + + const entities = + entityHints && entityHints.length > 0 + ? entityHints + : extractEntitiesFromQuery(query); + let graphResults: GraphRetrievalResult[] = []; + if (entities.length > 0) { + try { + graphResults = await this.graphRetrieval.searchByEntities( + entities, + 2, + limit, + ); + } catch { + // graph search is best-effort + } + } + + const topVectorObs = vectorResults.slice(0, 5).map((r) => r.obsId); + if (topVectorObs.length > 0) { + try { + const expansionResults = + await this.graphRetrieval.expandFromChunks(topVectorObs, 1, 5); + graphResults = [...graphResults, ...expansionResults]; + } catch { + // expansion is best-effort + } + } + + const scores = new Map< + string, + { + bm25Rank: number; + vectorRank: number; + graphRank: number; + sessionId: string; + bm25Score: number; + vectorScore: number; + graphScore: number; + graphContext?: string; + } + >(); + + bm25Results.forEach((r, i) => { + scores.set(r.obsId, { + bm25Rank: i + 1, + vectorRank: Infinity, + graphRank: Infinity, + sessionId: r.sessionId, + bm25Score: r.score, + vectorScore: 0, + graphScore: 0, + }); + }); + + vectorResults.forEach((r, i) => { + const existing = scores.get(r.obsId); + if (existing) { + existing.vectorRank = i + 1; + existing.vectorScore = r.score; + } else { + scores.set(r.obsId, { + bm25Rank: Infinity, + vectorRank: i + 1, + graphRank: Infinity, + sessionId: r.sessionId, + bm25Score: 0, + vectorScore: r.score, + graphScore: 0, + }); + } + }); + + graphResults.forEach((r, i) => { + const existing = scores.get(r.obsId); + if (existing) { + existing.graphRank = Math.min(existing.graphRank, i + 1); + existing.graphScore = Math.max(existing.graphScore, r.score); + if (r.graphContext && !existing.graphContext) { + existing.graphContext = r.graphContext; + } + } else { + scores.set(r.obsId, { + bm25Rank: Infinity, + vectorRank: Infinity, + graphRank: i + 1, + sessionId: r.sessionId, + bm25Score: 0, + vectorScore: 0, + graphScore: r.score, + graphContext: r.graphContext, + }); + } + }); + + 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; + } + + const combined = Array.from(scores.entries()).map(([obsId, s]) => ({ + 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)), + })); + + combined.sort((a, b) => b.combinedScore - a.combinedScore); + + const retrievalDepth = Math.max(limit, 20); + const rerankWindow = 20; + const diversified = this.diversifyBySession(combined, retrievalDepth); + const enriched = await this.enrichResults(diversified, retrievalDepth); + + if (this.rerankEnabled && enriched.length > 1) { + try { + const head = enriched.slice(0, rerankWindow); + const tail = enriched.slice(rerankWindow); + const reranked = await rerank(query, head, rerankWindow); + return reranked.concat(tail).slice(0, limit); + } catch { + return enriched.slice(0, limit); + } + } + + return enriched.slice(0, limit); + } + + private diversifyBySession( + results: Array<{ + obsId: string; + sessionId: string; + bm25Score: number; + vectorScore: number; + graphScore: number; + combinedScore: number; + graphContext?: string; + }>, + limit: number, + maxPerSession = 3, + ): typeof results { + const selected: typeof results = []; + const sessionCounts = new Map(); + + for (const r of results) { + const count = sessionCounts.get(r.sessionId) || 0; + if (count >= maxPerSession) continue; + selected.push(r); + sessionCounts.set(r.sessionId, count + 1); + if (selected.length >= limit) break; + } + + if (selected.length < limit) { + for (const r of results) { + if (selected.length >= limit) break; + if (!selected.some(s => s.obsId === r.obsId)) { + selected.push(r); + } + } + } + + return selected; + } + + private async enrichResults( + results: Array<{ + obsId: string; + sessionId: string; + bm25Score: number; + vectorScore: number; + graphScore: number; + combinedScore: number; + graphContext?: string; + }>, + limit: number, + ): Promise { + const sliced = results.slice(0, limit); + const observations = await Promise.all( + sliced.map(async (r) => { + const obs = await this.kv + .get(KV.observations(r.sessionId), r.obsId) + .catch(() => null); + if (obs) return obs; + // Fallback: indexed entry may originate from mem::remember, which + // writes to KV.memories with a synthetic sessionId ("memory" or the + // memory's first associated session). Coerce the Memory record into + // a CompressedObservation so search/recall surface saved memories. + const mem = await this.kv + .get(KV.memories, r.obsId) + .catch(() => null); + return mem ? memoryToObservation(mem) : null; + }), + ); + const enriched: HybridSearchResult[] = []; + for (let i = 0; i < sliced.length; i++) { + const obs = observations[i]; + if (obs) { + enriched.push({ + observation: obs, + bm25Score: sliced[i].bm25Score, + vectorScore: sliced[i].vectorScore, + graphScore: sliced[i].graphScore, + combinedScore: sliced[i].combinedScore, + sessionId: sliced[i].sessionId, + graphContext: sliced[i].graphContext, + }); + } + } + return enriched; + } +} diff --git a/src/state/index-persistence.ts b/src/state/index-persistence.ts new file mode 100644 index 0000000..6df0e2f --- /dev/null +++ b/src/state/index-persistence.ts @@ -0,0 +1,460 @@ +import { SearchIndex } from "./search-index.js"; +import { VectorIndex } from "./vector-index.js"; +import type { StateKV } from "./kv.js"; +import { KV, generateId } from "./schema.js"; +import { logger } from "../logger.js"; +import { safeAudit } from "../functions/audit.js"; + +const DEBOUNCE_MS = 5000; +const FAILURE_LOG_THROTTLE_MS = 60_000; +const INDEX_PERSISTENCE_FUNCTION_ID = "mem::index-persistence"; +const BM25_KEY = "data"; +const BM25_MANIFEST_KEY = "data:manifest"; +const BM25_SHARD_SCOPE_PREFIX = `${KV.bm25Index}:bm25:`; +const VECTOR_KEY = "vectors"; +const VECTOR_MANIFEST_KEY = "vectors:manifest"; +const VECTOR_SHARD_SCOPE_PREFIX = `${KV.bm25Index}:vectors:`; +const INDEX_SHARD_KEY = "data"; +const DEFAULT_INDEX_SHARD_CHARS = 2_000_000; + +type IndexShardManifest = { + v: 1; + generation?: string; + shards: Array<{ scope: string; key: string; chars: number }>; + chars: number; +}; + +type IndexPersistenceOptions = { + shardChars?: number; + createGeneration?: () => string; +}; + +function shardChars(options: IndexPersistenceOptions): number { + const configured = options.shardChars; + if (typeof configured !== "number" || !Number.isFinite(configured)) { + return DEFAULT_INDEX_SHARD_CHARS; + } + const wholeChars = Math.floor(configured); + return wholeChars >= 1 ? wholeChars : DEFAULT_INDEX_SHARD_CHARS; +} + +function createIndexGeneration(): string { + return generateId("idx"); +} + +function statePath(scope: string, key: string): string { + return `${scope}/${key}`; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isValidShardDescriptor( + shard: unknown, +): shard is IndexShardManifest["shards"][number] { + if (!shard || typeof shard !== "object") return false; + const candidate = shard as { scope?: unknown; key?: unknown; chars?: unknown }; + return ( + typeof candidate.scope === "string" && + candidate.scope.length > 0 && + typeof candidate.key === "string" && + candidate.key.length > 0 && + Number.isInteger(candidate.chars) && + candidate.chars >= 0 + ); +} + +export class IndexPersistence { + private timer: ReturnType | null = null; + private lastFailureLogAt = 0; + + constructor( + private kv: StateKV, + private bm25: SearchIndex, + private vector: VectorIndex | null, + private options: IndexPersistenceOptions = {}, + ) {} + + scheduleSave(): void { + if (this.timer) clearTimeout(this.timer); + // setTimeout discards the returned promise, so any rejection inside + // save() would surface as unhandledRejection and crash the process + // under sustained iii-engine write timeouts (issue #204). Funnel + // rejections through logFailure() instead. + this.timer = setTimeout(() => { + this.save().catch((err) => this.logFailure(err)); + }, DEBOUNCE_MS); + } + + async save(): Promise { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + try { + await this.saveBm25Index(this.bm25.serialize()); + if (this.vector) { + await this.saveVectorIndex(this.vector.serialize()); + } + } catch (err) { + this.logFailure(err); + } + } + + async load(): Promise<{ + bm25: SearchIndex | null; + vector: VectorIndex | null; + }> { + let bm25: SearchIndex | null = null; + let vector: VectorIndex | null = null; + + const bm25Data = await this.loadBm25Data(); + if (bm25Data && typeof bm25Data === "string") { + bm25 = SearchIndex.deserialize(bm25Data); + } + + const vecData = await this.loadVectorData(); + if (vecData && typeof vecData === "string") { + vector = VectorIndex.deserialize(vecData); + } + + return { bm25, vector }; + } + + stop(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + private logFailure(err: unknown): void { + const now = Date.now(); + // Throttle: persistence failures under load arrive in bursts + // (iii-engine queue pressure). Logging every debounce flush adds + // noise without information. + if (now - this.lastFailureLogAt < FAILURE_LOG_THROTTLE_MS) return; + this.lastFailureLogAt = now; + const code = (err as { code?: string })?.code; + const message = err instanceof Error ? err.message : String(err); + logger.warn("index persistence: failed to save BM25/vector index", { + code, + message, + hint: + code === "TIMEOUT" + ? "iii-engine state::set timed out; recent index updates remain in memory and will retry on the next debounce flush" + : undefined, + }); + } + + private async saveBm25Index(serialized: string): Promise { + await this.saveShardedIndex( + serialized, + BM25_MANIFEST_KEY, + BM25_KEY, + BM25_SHARD_SCOPE_PREFIX, + ); + } + + private async saveVectorIndex(serialized: string): Promise { + await this.saveShardedIndex( + serialized, + VECTOR_MANIFEST_KEY, + VECTOR_KEY, + VECTOR_SHARD_SCOPE_PREFIX, + ); + } + + private async saveShardedIndex( + serialized: string, + manifestKey: string, + legacyKey: string, + scopePrefix: string, + ): Promise { + const previous = await this.kv + .get(KV.bm25Index, manifestKey) + .catch(() => null); + const generation = + this.options.createGeneration?.() ?? createIndexGeneration(); + const chunkChars = shardChars(this.options); + const shards: IndexShardManifest["shards"] = []; + const chunks: string[] = []; + + for (let offset = 0; offset < serialized.length; offset += chunkChars) { + const shardIndex = shards.length; + const scope = `${scopePrefix}${generation}:${String(shardIndex).padStart( + 5, + "0", + )}`; + const chunk = serialized.slice(offset, offset + chunkChars); + shards.push({ scope, key: INDEX_SHARD_KEY, chars: chunk.length }); + chunks.push(chunk); + } + + const writeResults = await Promise.allSettled( + shards.map(async (shard, index) => { + const chunk = chunks[index] ?? ""; + await this.kv.set(shard.scope, shard.key, chunk); + await this.auditIndexPersistence("shard_write", [ + statePath(shard.scope, shard.key), + ], { + scope: shard.scope, + key: shard.key, + manifestKey, + generation, + chars: chunk.length, + }); + }), + ); + const failedWrite = writeResults.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failedWrite) { + await this.deleteShards(shards, "shard_write_rollback"); + throw failedWrite.reason; + } + + const nextManifest: IndexShardManifest = { + v: 1, + generation, + shards, + chars: serialized.length, + }; + try { + await this.kv.set( + KV.bm25Index, + manifestKey, + nextManifest, + ); + await this.auditIndexPersistence("manifest_publish", [ + statePath(KV.bm25Index, manifestKey), + ], { + manifestKey, + generation, + chars: serialized.length, + shards: shards.length, + result: "committed", + }); + } catch (err) { + if (await this.isManifestPublished(manifestKey, nextManifest)) { + await this.auditIndexPersistence("manifest_publish", [ + statePath(KV.bm25Index, manifestKey), + ], { + manifestKey, + generation, + chars: serialized.length, + shards: shards.length, + result: "committed_after_error", + error: errorMessage(err), + }); + } else { + await this.deleteShards(shards, "manifest_publish_rollback"); + } + throw err; + } + + await this.deleteKey(KV.bm25Index, legacyKey, "legacy_cleanup"); + if (previous?.v === 1 && Array.isArray(previous.shards)) { + const currentShardIds = new Set( + shards.map((shard) => `${shard.scope}\0${shard.key}`), + ); + for (const shard of previous.shards) { + if (currentShardIds.has(`${shard.scope}\0${shard.key}`)) continue; + await this.deleteShards([shard], "previous_generation_cleanup"); + } + } + } + + private async auditIndexPersistence( + action: string, + targetIds: string[], + details: Record, + ): Promise { + await safeAudit( + this.kv, + "index_persist", + INDEX_PERSISTENCE_FUNCTION_ID, + targetIds, + { action, ...details }, + ); + } + + private async deleteKey( + scope: string, + key: string, + reason: string, + ): Promise { + let result = "deleted"; + let error: string | undefined; + try { + await this.kv.delete(scope, key); + } catch (err) { + result = "failed"; + error = errorMessage(err); + } + await this.auditIndexPersistence("delete", [statePath(scope, key)], { + scope, + key, + reason, + result, + error, + }); + } + + private async deleteShards( + shards: IndexShardManifest["shards"], + reason: string, + ): Promise { + for (const shard of shards) { + await this.deleteKey(shard.scope, shard.key, reason); + } + } + + private async isManifestPublished( + manifestKey: string, + expected: IndexShardManifest, + ): Promise { + const published = await this.kv + .get(KV.bm25Index, manifestKey) + .catch(() => null); + if ( + published?.v !== 1 || + published.generation !== expected.generation || + published.chars !== expected.chars || + !Array.isArray(published.shards) || + published.shards.length !== expected.shards.length + ) { + return false; + } + return published.shards.every((shard, index) => { + const expectedShard = expected.shards[index]; + if (!expectedShard) return false; + return ( + shard.scope === expectedShard.scope && + shard.key === expectedShard.key && + shard.chars === expectedShard.chars + ); + }); + } + + private async loadBm25Data(): Promise { + return this.loadShardedData(BM25_KEY, BM25_MANIFEST_KEY, "BM25"); + } + + private async loadVectorData(): Promise { + return this.loadShardedData(VECTOR_KEY, VECTOR_MANIFEST_KEY, "vector"); + } + + private async loadShardedData( + legacyKey: string, + manifestKey: string, + label: string, + ): Promise { + const manifest = await this.readIndexValue( + KV.bm25Index, + manifestKey, + label, + "manifest", + ); + if (!manifest.ok) return null; + // #797: some iii-state adapters return `undefined` (not `null`) for + // a missing key. The previous `value !== null` check passed + // undefined through to loadManifestData, which then crashed on + // `manifest.v` with TypeError. Treat both null and undefined as + // "no manifest" and fall through to the legacy path. The shape + // check stays so a malformed-but-present row still fails closed. + if ( + manifest.value != null && + typeof manifest.value === "object" + ) { + return this.loadManifestData(manifest.value, label); + } + + const legacy = await this.readIndexValue( + KV.bm25Index, + legacyKey, + label, + "legacy", + ); + if (!legacy.ok) return null; + if (legacy.value && typeof legacy.value === "string") return legacy.value; + return null; + } + + private async readIndexValue( + scope: string, + key: string, + label: string, + source: "manifest" | "legacy", + ): Promise<{ ok: true; value: T | null } | { ok: false }> { + try { + return { ok: true, value: await this.kv.get(scope, key) }; + } catch (err) { + logger.warn(`index persistence: ${label} ${source} read failed`, { + scope, + key, + message: errorMessage(err), + }); + return { ok: false }; + } + } + + private async loadManifestData( + manifest: IndexShardManifest, + label: string, + ): Promise { + if ( + manifest.v !== 1 || + !Array.isArray(manifest.shards) || + manifest.shards.length === 0 || + !Number.isInteger(manifest.chars) || + manifest.chars < 0 + ) { + logger.warn(`index persistence: ${label} shard manifest invalid`); + return null; + } + for (const shard of manifest.shards) { + if (!isValidShardDescriptor(shard)) { + logger.warn(`index persistence: ${label} shard manifest invalid`); + return null; + } + } + const loadedShards = await Promise.all( + manifest.shards.map(async (shard) => ({ + shard, + chunk: await this.kv.get(shard.scope, shard.key).catch(() => null), + })), + ); + const chunks: string[] = []; + let chars = 0; + for (const { shard, chunk } of loadedShards) { + if (typeof chunk !== "string") { + logger.warn(`index persistence: ${label} shard missing`, { + scope: shard.scope, + key: shard.key, + }); + return null; + } + if (chunk.length !== shard.chars) { + logger.warn(`index persistence: ${label} shard length mismatch`, { + scope: shard.scope, + key: shard.key, + expected: shard.chars, + actual: chunk.length, + }); + return null; + } + chunks.push(chunk); + chars += chunk.length; + } + if (chars !== manifest.chars) { + logger.warn(`index persistence: ${label} total length mismatch`, { + expected: manifest.chars, + actual: chars, + }); + return null; + } + return chunks.join(""); + } +} diff --git a/src/state/keyed-mutex.ts b/src/state/keyed-mutex.ts new file mode 100644 index 0000000..b28531a --- /dev/null +++ b/src/state/keyed-mutex.ts @@ -0,0 +1,18 @@ +const locks = new Map>(); + +export function withKeyedLock( + key: string, + fn: () => Promise, +): Promise { + const prev = locks.get(key) ?? Promise.resolve(); + const next = prev.then(fn, fn); + const cleanup = next.then( + () => {}, + () => {}, + ); + locks.set(key, cleanup); + cleanup.then(() => { + if (locks.get(key) === cleanup) locks.delete(key); + }); + return next; +} diff --git a/src/state/kv.ts b/src/state/kv.ts new file mode 100644 index 0000000..0c0e6cc --- /dev/null +++ b/src/state/kv.ts @@ -0,0 +1,47 @@ +import type { ISdk } from 'iii-sdk' + +export class StateKV { + constructor(private sdk: ISdk) {} + + async get(scope: string, key: string): Promise { + return this.sdk.trigger<{ scope: string; key: string }, T | null>({ + function_id: 'state::get', + payload: { scope, key }, + }) + } + + async set(scope: string, key: string, value: T): Promise { + return this.sdk.trigger<{ scope: string; key: string; value: T }, T>({ + function_id: 'state::set', + payload: { scope, key, value }, + }) + } + + async update( + scope: string, + key: string, + ops: Array<{ type: string; path: string; value?: unknown }>, + ): Promise { + return this.sdk.trigger< + { scope: string; key: string; ops: Array<{ type: string; path: string; value?: unknown }> }, + T + >({ + function_id: 'state::update', + payload: { scope, key, ops }, + }) + } + + async delete(scope: string, key: string): Promise { + return this.sdk.trigger<{ scope: string; key: string }, void>({ + function_id: 'state::delete', + payload: { scope, key }, + }) + } + + async list(scope: string): Promise { + return this.sdk.trigger<{ scope: string }, T[]>({ + function_id: 'state::list', + payload: { scope }, + }) + } +} diff --git a/src/state/memory-utils.ts b/src/state/memory-utils.ts new file mode 100644 index 0000000..aa0bcc5 --- /dev/null +++ b/src/state/memory-utils.ts @@ -0,0 +1,24 @@ +import type { CompressedObservation, Memory } from "../types.js"; + +// Wraps a Memory record in the CompressedObservation shape that +// SearchIndex / VectorIndex / enrichment paths consume. Memories share +// the same searchable fields as observations (title + content + +// concepts + files); type is normalized to "decision" so memories stay +// distinguishable in result metadata without colliding with observation +// enums (file_read, command_run, …). The synthetic sessionId +// ("memory" or memory.sessionIds[0]) is what enrich-side fallbacks key +// off of when looking up the source record in KV.memories. +export function memoryToObservation(memory: Memory): CompressedObservation { + return { + id: memory.id, + sessionId: memory.sessionIds?.[0] ?? "memory", + timestamp: memory.createdAt, + type: "decision", + title: memory.title, + facts: [memory.content], + narrative: memory.content, + concepts: memory.concepts, + files: memory.files, + importance: memory.strength, + }; +} diff --git a/src/state/reranker.ts b/src/state/reranker.ts new file mode 100644 index 0000000..d0aae68 --- /dev/null +++ b/src/state/reranker.ts @@ -0,0 +1,74 @@ +import type { HybridSearchResult } from "../types.js"; + +let pipeline: any = null; +let pipelineLoading: Promise | null = null; +let pipelineUnavailable = false; + +async function loadPipeline(): Promise { + if (pipelineUnavailable) return null; + if (pipeline) return pipeline; + if (pipelineLoading) return pipelineLoading; + + pipelineLoading = (async () => { + try { + const { pipeline: createPipeline } = await import( + "@xenova/transformers" + ); + pipeline = await createPipeline( + "text-classification", + "Xenova/ms-marco-MiniLM-L-6-v2", + { quantized: true }, + ); + return pipeline; + } catch { + pipeline = null; + pipelineUnavailable = true; + return null; + } finally { + pipelineLoading = null; + } + })(); + return pipelineLoading; +} + +export async function rerank( + query: string, + results: HybridSearchResult[], + topK = 20, +): Promise { + if (results.length <= 1) return results; + + const reranker = await loadPipeline(); + if (!reranker) return results; + + const candidates = results.slice(0, Math.min(results.length, topK)); + + const pairs = candidates.map((r) => ({ + text: `${query} [SEP] ${r.observation.title || ""} ${r.observation.narrative || ""}`.slice(0, 512), + result: r, + })); + + const scores: Array<{ result: HybridSearchResult; rerankScore: number }> = []; + + for (const pair of pairs) { + try { + const output = await reranker(pair.text); + const score = Array.isArray(output) ? output[0]?.score ?? 0 : 0; + scores.push({ result: pair.result, rerankScore: score }); + } catch { + scores.push({ result: pair.result, rerankScore: pair.result.combinedScore }); + } + } + + scores.sort((a, b) => b.rerankScore - a.rerankScore); + + return scores.map((s, i) => ({ + ...s.result, + combinedScore: s.rerankScore, + rerankPosition: i + 1, + })); +} + +export function isRerankerAvailable(): boolean { + return pipeline !== null; +} diff --git a/src/state/schema.ts b/src/state/schema.ts new file mode 100644 index 0000000..cb29d41 --- /dev/null +++ b/src/state/schema.ts @@ -0,0 +1,104 @@ +import { createHash } from "node:crypto"; + +export const KV = { + sessions: "mem:sessions", + observations: (sessionId: string) => `mem:obs:${sessionId}`, + memories: "mem:memories", + summaries: "mem:summaries", + config: "mem:config", + metrics: "mem:metrics", + health: "mem:health", + embeddings: (obsId: string) => `mem:emb:${obsId}`, + bm25Index: "mem:index:bm25", + relations: "mem:relations", + profiles: "mem:profiles", + claudeBridge: "mem:claude-bridge", + graphNodes: "mem:graph:nodes", + graphEdges: "mem:graph:edges", + // #814: precomputed snapshot of the top-degree subgraph and aggregate + // type counts. Saves /graph/query and /graph/stats from a full + // kv.list enumeration over 75K+ node corpora, which exceeds the iii + // invocation timeout and surfaces as "Invocation stopped" 500s. + // Single fixed key ("current") so writes are read-modify-write under + // the same keyed mutex as graph-extract. + graphSnapshot: "mem:graph:snapshot", + // #814 v2: targeted-lookup indexes so graph-extract never enumerates + // the full nodes/edges scope. Each entry is a single small kv.get, + // bounded payload — works at 75K+ nodes where kv.list would block + // the worker event loop (37MB WS frame parse blocks heartbeat, + // worker is declared dead before any Promise.race timer can fire). + // - graphNameIndex: key `${type}|${name}` -> nodeId. Replaces the + // existingNodes.find() O(n) dedup scan inside mem::graph-extract. + // - graphEdgeKey: key `${src}|${tgt}|${type}` -> edgeId. Same for + // edge dedup. + // - graphNodeDegree: key nodeId -> incident-edge count. Read / + // incremented on edge writes to maintain the snapshot top-N + // ranking without scanning all edges. + graphNameIndex: "mem:graph:name-index", + graphEdgeKey: "mem:graph:edge-key", + graphNodeDegree: "mem:graph:node-degree", + semantic: "mem:semantic", + procedural: "mem:procedural", + teamShared: (teamId: string) => `mem:team:${teamId}:shared`, + teamUsers: (teamId: string, userId: string) => + `mem:team:${teamId}:users:${userId}`, + teamProfile: (teamId: string) => `mem:team:${teamId}:profile`, + audit: "mem:audit", + actions: "mem:actions", + actionEdges: "mem:action-edges", + leases: "mem:leases", + routines: "mem:routines", + routineRuns: "mem:routine-runs", + signals: "mem:signals", + checkpoints: "mem:checkpoints", + mesh: "mem:mesh", + sketches: "mem:sketches", + facets: "mem:facets", + sentinels: "mem:sentinels", + crystals: "mem:crystals", + lessons: "mem:lessons", + insights: "mem:insights", + graphEdgeHistory: "mem:graph:edge-history", + enrichedChunks: (sessionId: string) => `mem:enriched:${sessionId}`, + latentEmbeddings: (obsId: string) => `mem:latent:${obsId}`, + retentionScores: "mem:retention", + accessLog: "mem:access", + imageRefs: "mem:image-refs", + imageEmbeddings: "mem:image-embeddings", + slots: "mem:slots", + globalSlots: "mem:slots:global", + state: "mem:state", + commits: "mem:commits", + // #771: tracks the most recent smart-search call per session, used by + // the followup-rate diagnostic. Key = sessionId. TTL-swept hourly. + recentSearches: "mem:recent-searches", +} as const; + +export const STREAM = { + name: "mem-live", + group: (sessionId: string) => sessionId, + viewerGroup: "viewer", +} as const; + +export function generateId(prefix: string): string { + const ts = Date.now().toString(36); + const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 12); + return `${prefix}_${ts}_${rand}`; +} + +export function fingerprintId(prefix: string, content: string): string { + const hash = createHash("sha256").update(content).digest("hex"); + return `${prefix}_${hash.slice(0, 16)}`; +} + +export function jaccardSimilarity(a: string, b: string): number { + const setA = new Set(a.split(/\s+/).filter((t) => t.length > 2)); + const setB = new Set(b.split(/\s+/).filter((t) => t.length > 2)); + if (setA.size === 0 && setB.size === 0) return 1; + if (setA.size === 0 || setB.size === 0) return 0; + let intersection = 0; + for (const word of setA) { + if (setB.has(word)) intersection++; + } + return intersection / (setA.size + setB.size - intersection); +} diff --git a/src/state/search-index.ts b/src/state/search-index.ts new file mode 100644 index 0000000..67f3e2e --- /dev/null +++ b/src/state/search-index.ts @@ -0,0 +1,281 @@ +import type { CompressedObservation } from "../types.js"; +import { stem } from "./stemmer.js"; +import { getSynonyms } from "./synonyms.js"; +import { segmentCjk, hasCjk } from "./cjk-segmenter.js"; + +interface IndexEntry { + obsId: string; + sessionId: string; + termCount: number; +} + +export class SearchIndex { + private entries: Map = new Map(); + private invertedIndex: Map> = new Map(); + private docTermCounts: Map> = new Map(); + private totalDocLength = 0; + private sortedTerms: string[] | null = null; + + private readonly k1 = 1.2; + private readonly b = 0.75; + + add(obs: CompressedObservation): void { + const terms = this.extractTerms(obs); + const termFreq = new Map(); + let termCount = 0; + + for (const term of terms) { + termFreq.set(term, (termFreq.get(term) || 0) + 1); + termCount++; + } + + this.entries.set(obs.id, { + obsId: obs.id, + sessionId: obs.sessionId, + termCount, + }); + this.docTermCounts.set(obs.id, termFreq); + this.totalDocLength += termCount; + + for (const term of termFreq.keys()) { + if (!this.invertedIndex.has(term)) { + this.invertedIndex.set(term, new Set()); + } + this.invertedIndex.get(term)!.add(obs.id); + } + + this.sortedTerms = null; + } + + has(id: string): boolean { + return this.entries.has(id); + } + + remove(id: string): void { + const entry = this.entries.get(id); + if (!entry) return; + + const termFreq = this.docTermCounts.get(id); + if (termFreq) { + for (const term of termFreq.keys()) { + const postingList = this.invertedIndex.get(term); + if (postingList) { + postingList.delete(id); + if (postingList.size === 0) { + this.invertedIndex.delete(term); + } + } + } + this.docTermCounts.delete(id); + } + + this.totalDocLength = Math.max(0, this.totalDocLength - entry.termCount); + this.entries.delete(id); + this.sortedTerms = null; + } + + search( + query: string, + limit = 20, + ): Array<{ obsId: string; sessionId: string; score: number }> { + const rawTerms = this.tokenize(query.toLowerCase()); + if (rawTerms.length === 0) return []; + + const N = this.entries.size; + if (N === 0) return []; + const avgDocLen = this.totalDocLength / N; + + const queryTerms: Array<{ term: string; weight: number }> = []; + const seen = new Set(); + for (const term of rawTerms) { + if (!seen.has(term)) { + seen.add(term); + queryTerms.push({ term, weight: 1.0 }); + } + for (const syn of getSynonyms(term)) { + if (!seen.has(syn)) { + seen.add(syn); + queryTerms.push({ term: syn, weight: 0.7 }); + } + } + } + + const scores = new Map(); + const sorted = this.getSortedTerms(); + + for (const { term, weight } of queryTerms) { + const matchingDocs = this.invertedIndex.get(term); + if (matchingDocs) { + const df = matchingDocs.size; + const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1); + + for (const obsId of matchingDocs) { + const entry = this.entries.get(obsId)!; + const docTerms = this.docTermCounts.get(obsId); + const tf = docTerms?.get(term) || 0; + const docLen = entry.termCount; + + const numerator = tf * (this.k1 + 1); + const denominator = + tf + this.k1 * (1 - this.b + this.b * (docLen / avgDocLen)); + const bm25Score = idf * (numerator / denominator) * weight; + + scores.set(obsId, (scores.get(obsId) || 0) + bm25Score); + } + } + + const startIdx = this.lowerBound(sorted, term); + for (let si = startIdx; si < sorted.length; si++) { + const indexTerm = sorted[si]; + if (!indexTerm.startsWith(term)) break; + if (indexTerm === term) continue; + + const obsIds = this.invertedIndex.get(indexTerm)!; + const prefixDf = obsIds.size; + const prefixIdf = + Math.log((N - prefixDf + 0.5) / (prefixDf + 0.5) + 1) * 0.5; + for (const obsId of obsIds) { + const entry = this.entries.get(obsId)!; + const docTerms = this.docTermCounts.get(obsId); + const tf = docTerms?.get(indexTerm) || 0; + const docLen = entry.termCount; + const numerator = tf * (this.k1 + 1); + const denominator = + tf + this.k1 * (1 - this.b + this.b * (docLen / avgDocLen)); + scores.set( + obsId, + (scores.get(obsId) || 0) + prefixIdf * (numerator / denominator) * weight, + ); + } + } + } + + return Array.from(scores.entries()) + .map(([obsId, score]) => { + const entry = this.entries.get(obsId)!; + return { obsId, sessionId: entry.sessionId, score }; + }) + .sort((a, b) => b.score - a.score) + .slice(0, limit); + } + + get size(): number { + return this.entries.size; + } + + clear(): void { + this.entries.clear(); + this.invertedIndex.clear(); + this.docTermCounts.clear(); + this.totalDocLength = 0; + this.sortedTerms = null; + } + + restoreFrom(other: SearchIndex): void { + this.entries = new Map( + Array.from(other.entries.entries()).map(([k, v]) => [k, { ...v }]), + ); + this.invertedIndex = new Map( + Array.from(other.invertedIndex.entries()).map(([k, v]) => [ + k, + new Set(v), + ]), + ); + this.docTermCounts = new Map( + Array.from(other.docTermCounts.entries()).map(([k, v]) => [ + k, + new Map(v), + ]), + ); + this.totalDocLength = other.totalDocLength; + this.sortedTerms = null; + } + + serialize(): string { + const entries = Array.from(this.entries.entries()); + const inverted = Array.from(this.invertedIndex.entries()).map( + ([term, ids]) => [term, Array.from(ids)] as [string, string[]], + ); + const docTerms = Array.from(this.docTermCounts.entries()).map( + ([id, counts]) => + [id, Array.from(counts.entries())] as [string, [string, number][]], + ); + return JSON.stringify({ + v: 2, + entries, + inverted, + docTerms, + totalDocLength: this.totalDocLength, + }); + } + + static deserialize(json: string): SearchIndex { + try { + const idx = new SearchIndex(); + const data = JSON.parse(json); + if (!data?.entries || !data?.inverted || !data?.docTerms) return idx; + for (const [key, val] of data.entries) { + idx.entries.set(key, val); + } + for (const [term, ids] of data.inverted) { + idx.invertedIndex.set(term, new Set(ids)); + } + for (const [id, counts] of data.docTerms) { + idx.docTermCounts.set(id, new Map(counts)); + } + const rawLen = Number(data.totalDocLength); + idx.totalDocLength = + Number.isFinite(rawLen) && rawLen >= 0 ? Math.floor(rawLen) : 0; + return idx; + } catch { + return new SearchIndex(); + } + } + + private extractTerms(obs: CompressedObservation): string[] { + const parts = [ + obs.title, + obs.subtitle || "", + obs.narrative, + ...obs.facts, + ...obs.concepts, + ...obs.files, + obs.type, + ]; + return this.tokenize(parts.join(" ").toLowerCase()); + } + + private tokenize(text: string): string[] { + const cleaned = text.replace(/[^\p{L}\p{N}\s/.\\-_]/gu, " "); + const out: string[] = []; + for (const raw of cleaned.split(/\s+/)) { + if (raw.length < 2) continue; + if (hasCjk(raw)) { + for (const seg of segmentCjk(raw)) { + if (seg.length >= 1) out.push(seg); + } + } else { + out.push(stem(raw)); + } + } + return out; + } + + private getSortedTerms(): string[] { + if (!this.sortedTerms) { + this.sortedTerms = Array.from(this.invertedIndex.keys()).sort(); + } + return this.sortedTerms; + } + + private lowerBound(arr: string[], target: string): number { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (arr[mid] < target) lo = mid + 1; + else hi = mid; + } + return lo; + } +} diff --git a/src/state/stemmer.ts b/src/state/stemmer.ts new file mode 100644 index 0000000..7f21096 --- /dev/null +++ b/src/state/stemmer.ts @@ -0,0 +1,104 @@ +const step2map: Record = { + ational: "ate", tional: "tion", enci: "ence", anci: "ance", + izer: "ize", iser: "ise", abli: "able", alli: "al", + entli: "ent", eli: "e", ousli: "ous", ization: "ize", + isation: "ise", ation: "ate", ator: "ate", alism: "al", + iveness: "ive", fulness: "ful", ousness: "ous", aliti: "al", + iviti: "ive", biliti: "ble", +}; + +const step3map: Record = { + icate: "ic", ative: "", alize: "al", alise: "al", + iciti: "ic", ical: "ic", ful: "", ness: "", +}; + +function hasVowel(s: string): boolean { + return /[aeiou]/.test(s); +} + +function measure(s: string): number { + const reduced = s.replace(/[^aeiouy]+/g, "C").replace(/[aeiouy]+/g, "V"); + const m = reduced.match(/VC/g); + return m ? m.length : 0; +} + +function endsDoubleConsonant(s: string): boolean { + return s.length >= 2 && s[s.length - 1] === s[s.length - 2] && !/[aeiou]/.test(s[s.length - 1]); +} + +function endsCVC(s: string): boolean { + if (s.length < 3) return false; + const c1 = s[s.length - 3], v = s[s.length - 2], c2 = s[s.length - 1]; + return !/[aeiou]/.test(c1) && /[aeiou]/.test(v) && !/[aeiouwxy]/.test(c2); +} + +export function stem(word: string): string { + if (word.length <= 2) return word; + + let w = word; + + if (w.endsWith("sses")) w = w.slice(0, -2); + else if (w.endsWith("ies")) w = w.slice(0, -2); + else if (!w.endsWith("ss") && w.endsWith("s")) w = w.slice(0, -1); + + if (w.endsWith("eed")) { + if (measure(w.slice(0, -3)) > 0) w = w.slice(0, -1); + } else if (w.endsWith("ed") && hasVowel(w.slice(0, -2))) { + w = w.slice(0, -2); + if (w.endsWith("at") || w.endsWith("bl") || w.endsWith("iz")) w += "e"; + else if (endsDoubleConsonant(w) && !/[lsz]$/.test(w)) w = w.slice(0, -1); + else if (measure(w) === 1 && endsCVC(w)) w += "e"; + } else if (w.endsWith("ing") && hasVowel(w.slice(0, -3))) { + w = w.slice(0, -3); + if (w.endsWith("at") || w.endsWith("bl") || w.endsWith("iz")) w += "e"; + else if (endsDoubleConsonant(w) && !/[lsz]$/.test(w)) w = w.slice(0, -1); + else if (measure(w) === 1 && endsCVC(w)) w += "e"; + } + + if (w.endsWith("y") && hasVowel(w.slice(0, -1))) { + w = w.slice(0, -1) + "i"; + } + + for (const [suffix, replacement] of Object.entries(step2map)) { + if (w.endsWith(suffix)) { + const base = w.slice(0, -suffix.length); + if (measure(base) > 0) w = base + replacement; + break; + } + } + + for (const [suffix, replacement] of Object.entries(step3map)) { + if (w.endsWith(suffix)) { + const base = w.slice(0, -suffix.length); + if (measure(base) > 0) w = base + replacement; + break; + } + } + + if (w.endsWith("al") || w.endsWith("ance") || w.endsWith("ence") || + w.endsWith("er") || w.endsWith("ic") || w.endsWith("able") || + w.endsWith("ible") || w.endsWith("ant") || w.endsWith("ement") || + w.endsWith("ment") || w.endsWith("ent") || w.endsWith("tion") || + w.endsWith("sion") || w.endsWith("ou") || w.endsWith("ism") || + w.endsWith("ate") || w.endsWith("iti") || w.endsWith("ous") || + w.endsWith("ive") || w.endsWith("ize") || w.endsWith("ise")) { + const suffixLen = w.match(/(ement|ment|tion|sion|ance|ence|able|ible|ism|ate|iti|ous|ive|ize|ise|ant|ent|al|er|ic|ou)$/)?.[0]?.length ?? 0; + if (suffixLen > 0) { + const base = w.slice(0, -suffixLen); + if (measure(base) > 1) w = base; + } + } + + if (w.endsWith("e")) { + const base = w.slice(0, -1); + if (measure(base) > 1 || (measure(base) === 1 && !endsCVC(base))) { + w = base; + } + } + + if (endsDoubleConsonant(w) && w.endsWith("l") && measure(w.slice(0, -1)) > 1) { + w = w.slice(0, -1); + } + + return w; +} diff --git a/src/state/synonyms.ts b/src/state/synonyms.ts new file mode 100644 index 0000000..0dab415 --- /dev/null +++ b/src/state/synonyms.ts @@ -0,0 +1,63 @@ +import { stem } from "./stemmer.js"; + +const SYNONYM_GROUPS: string[][] = [ + ["auth", "authentication", "authn", "authenticating"], + ["authz", "authorization", "authorizing"], + ["db", "database", "datastore"], + ["perf", "performance", "latency", "throughput", "slow", "bottleneck"], + ["optim", "optimization", "optimizing", "optimise", "query-optimization"], + ["k8s", "kubernetes", "kube"], + ["config", "configuration", "configuring", "setup"], + ["deps", "dependencies", "dependency"], + ["env", "environment"], + ["fn", "function"], + ["impl", "implementation", "implementing"], + ["msg", "message", "messaging"], + ["repo", "repository"], + ["req", "request"], + ["res", "response"], + ["ts", "typescript"], + ["js", "javascript"], + ["pg", "postgres", "postgresql"], + ["err", "error", "errors"], + ["api", "endpoint", "endpoints"], + ["ci", "continuous-integration"], + ["cd", "continuous-deployment"], + ["test", "testing", "tests"], + ["doc", "documentation", "docs"], + ["infra", "infrastructure"], + ["deploy", "deployment", "deploying"], + ["cache", "caching", "cached"], + ["log", "logging", "logs"], + ["monitor", "monitoring"], + ["observe", "observability"], + ["sec", "security", "secure"], + ["validate", "validation", "validating"], + ["migrate", "migration", "migrations"], + ["debug", "debugging"], + ["container", "containerization", "docker"], + ["crash", "crashloop", "crashloopbackoff"], + ["webhook", "webhooks", "callback"], + ["middleware", "mw"], + ["paginate", "pagination"], + ["serialize", "serialization"], + ["encrypt", "encryption"], + ["hash", "hashing"], +]; + +const synonymMap = new Map>(); + +for (const group of SYNONYM_GROUPS) { + const stemmed = group.map(t => stem(t.toLowerCase())); + for (const s of stemmed) { + if (!synonymMap.has(s)) synonymMap.set(s, new Set()); + for (const other of stemmed) { + if (other !== s) synonymMap.get(s)!.add(other); + } + } +} + +export function getSynonyms(stemmedTerm: string): string[] { + const syns = synonymMap.get(stemmedTerm); + return syns ? [...syns] : []; +} diff --git a/src/state/vector-index.ts b/src/state/vector-index.ts new file mode 100644 index 0000000..d4b8bda --- /dev/null +++ b/src/state/vector-index.ts @@ -0,0 +1,167 @@ +// Pass byteOffset + byteLength explicitly so the round-trip survives +// Node's Buffer pool. Buffer.from(b64, "base64") returns a slice of a +// shared 8KB pool (poolSize), and `new Float32Array(buf.buffer)` ignores +// the slice metadata — it would mint a 2048-element view over the whole +// pool. Same risk on the encode side if the input Float32Array is itself +// a sliced view. Reported as a phantom "2048 dimensions on disk" crash +// in #455 / #469 / #584 / #587. +function float32ToBase64(arr: Float32Array): string { + return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength).toString( + "base64", + ); +} + +function base64ToFloat32(b64: string): Float32Array { + const buf = Buffer.from(b64, "base64"); + return new Float32Array( + buf.buffer, + buf.byteOffset, + buf.byteLength / Float32Array.BYTES_PER_ELEMENT, + ); +} + +function cosineSimilarity(a: Float32Array, b: Float32Array): number { + if (a.length !== b.length) return 0; + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom === 0 ? 0 : dot / denom; +} + +export class VectorIndex { + private vectors: Map = + new Map(); + + add(obsId: string, sessionId: string, embedding: Float32Array): void { + this.vectors.set(obsId, { embedding, sessionId }); + } + + remove(obsId: string): void { + this.vectors.delete(obsId); + } + + search( + query: Float32Array, + limit = 20, + ): Array<{ obsId: string; sessionId: string; score: number }> { + const results: Array<{ + obsId: string; + sessionId: string; + score: number; + }> = []; + let minScore = -Infinity; + + for (const [obsId, entry] of this.vectors) { + const score = cosineSimilarity(query, entry.embedding); + if (results.length < limit) { + results.push({ obsId, sessionId: entry.sessionId, score }); + if (results.length === limit) { + results.sort((a, b) => a.score - b.score); + minScore = results[0].score; + } + } else if (score > minScore) { + results[0] = { obsId, sessionId: entry.sessionId, score }; + results.sort((a, b) => a.score - b.score); + minScore = results[0].score; + } + } + + results.sort((a, b) => b.score - a.score); + return results; + } + + get size(): number { + return this.vectors.size; + } + + // Walks every stored vector and returns the obsIds whose dimension + // doesn't match `expected`, plus the set of distinct dimensions seen. + // Used by the persistence-restore guard in src/index.ts to refuse + // loading any index containing wrong-dimension vectors — including + // legacy on-disk indexes written before the live-API dimension guard + // existed (where a mid-session provider swap could mix dimensions + // inside a single index). Empty `mismatches` plus a single-entry + // `seenDimensions` matching `expected` is the only clean state. + validateDimensions( + expected: number, + ): { mismatches: Array<{ obsId: string; dim: number }>; seenDimensions: Set } { + const mismatches: Array<{ obsId: string; dim: number }> = []; + const seenDimensions = new Set(); + for (const [obsId, entry] of this.vectors) { + const dim = entry.embedding.length; + seenDimensions.add(dim); + if (dim !== expected) { + mismatches.push({ obsId, dim }); + } + } + return { mismatches, seenDimensions }; + } + + clear(): void { + this.vectors.clear(); + } + + restoreFrom(other: VectorIndex): void { + const src = (other as any).vectors as Map< + string, + { embedding: Float32Array; sessionId: string } + >; + this.vectors = new Map(); + for (const [obsId, entry] of src) { + this.vectors.set(obsId, { + embedding: new Float32Array(entry.embedding), + sessionId: entry.sessionId, + }); + } + } + + serialize(): string { + const data: Array<[string, { embedding: string; sessionId: string }]> = []; + for (const [obsId, entry] of this.vectors) { + data.push([ + obsId, + { + embedding: float32ToBase64(entry.embedding), + sessionId: entry.sessionId, + }, + ]); + } + return JSON.stringify(data); + } + + static deserialize(json: string): VectorIndex { + const idx = new VectorIndex(); + let data: unknown; + try { + data = JSON.parse(json); + } catch { + return idx; + } + if (!Array.isArray(data)) return idx; + for (const row of data) { + try { + if (!Array.isArray(row) || row.length < 2) continue; + const [obsId, entry] = row; + if ( + typeof obsId !== "string" || + typeof entry?.embedding !== "string" || + typeof entry?.sessionId !== "string" + ) + continue; + idx.vectors.set(obsId, { + embedding: base64ToFloat32(entry.embedding), + sessionId: entry.sessionId, + }); + } catch { + continue; + } + } + return idx; + } +} diff --git a/src/telemetry/setup.ts b/src/telemetry/setup.ts new file mode 100644 index 0000000..fc55b4f --- /dev/null +++ b/src/telemetry/setup.ts @@ -0,0 +1,149 @@ +import { VERSION } from "../version.js"; + +interface OtelConfig { + serviceName: string; + serviceVersion: string; + metricsExportIntervalMs: number; +} + +export const OTEL_CONFIG: OtelConfig = { + serviceName: "agentmemory", + serviceVersion: VERSION, + metricsExportIntervalMs: 30_000, +}; + +interface Counter { + add: (n: number) => void; +} +interface Histogram { + record: (v: number) => void; +} + +interface Counters { + observationsTotal: Counter; + compressionSuccess: Counter; + compressionFailure: Counter; + searchTotal: Counter; + dedupSkipped: Counter; + evictionTotal: Counter; + circuitBreakerOpen: Counter; + embeddingSuccess: Counter; + embeddingFailure: Counter; + vectorSearchTotal: Counter; + autoForgetTotal: Counter; + profileGenerated: Counter; + claudeBridgeSync: Counter; + graphExtraction: Counter; + consolidationRun: Counter; + teamShare: Counter; + auditLog: Counter; + snapshotCreate: Counter; + governanceDelete: Counter; + // #771: smart-search follow-up proxy. Incremented when a session + // issues a second smart-search inside the configured window AND the + // new result set has zero overlap with the previous one (a + // directional signal for "the first results didn't satisfy"). Treat + // as directional, not absolute — legitimate query refinement counts + // here too. + smartSearchFollowupWithinWindow: Counter; + // #771: benchmark-mode counter. Incremented by the benchmark scorer + // when judge_correct === false AND the gold-evidence-IDs are a + // subset of the retrieved-context-IDs (reader missed the answer + // despite retrieval being correct). Never incremented by core in + // live use; reserved here so dashboards keep a stable name. + readerFailureWithEvidence: Counter; +} + +interface Histograms { + compressionLatency: Histogram; + searchLatency: Histogram; + contextTokens: Histogram; + qualityScore: Histogram; + embeddingLatency: Histogram; + vectorSearchLatency: Histogram; +} + +type Meter = { + createCounter: (name: string) => Counter; + createHistogram: (name: string) => Histogram; +}; + +let counters: Counters | null = null; +let histograms: Histograms | null = null; + +const NOOP_COUNTER: Counter = { add: () => {} }; +const NOOP_HISTOGRAM: Histogram = { record: () => {} }; + +const COUNTER_NAMES: Array<[keyof Counters, string]> = [ + ["observationsTotal", "observations.total"], + ["compressionSuccess", "compression.success"], + ["compressionFailure", "compression.failure"], + ["searchTotal", "search.total"], + ["dedupSkipped", "dedup.skipped"], + ["evictionTotal", "eviction.total"], + ["circuitBreakerOpen", "circuit_breaker.open"], + ["embeddingSuccess", "embedding.success"], + ["embeddingFailure", "embedding.failure"], + ["vectorSearchTotal", "vector_search.total"], + ["autoForgetTotal", "auto_forget.total"], + ["profileGenerated", "profile.generated"], + ["claudeBridgeSync", "claude_bridge.sync"], + ["graphExtraction", "graph.extraction"], + ["consolidationRun", "consolidation.run"], + ["teamShare", "team.share"], + ["auditLog", "audit.log"], + ["snapshotCreate", "snapshot.create"], + ["governanceDelete", "governance.delete"], + ["smartSearchFollowupWithinWindow", "smart_search.followup_within_window_total"], + ["readerFailureWithEvidence", "reader_failure_with_evidence_total"], +]; + +const HISTOGRAM_NAMES: Array<[keyof Histograms, string]> = [ + ["compressionLatency", "compression.latency_ms"], + ["searchLatency", "search.latency_ms"], + ["contextTokens", "context.tokens"], + ["qualityScore", "quality.score"], + ["embeddingLatency", "embedding.latency_ms"], + ["vectorSearchLatency", "vector_search.latency_ms"], +]; + +// Accessors so functions outside `initMetrics`'s closure can record into +// the same counter / histogram instances after init. Before init both +// return no-op fallbacks so call sites stay safe in tests and during +// the boot window. +export function getCounters(): Counters { + if (counters) return counters; + return Object.fromEntries( + COUNTER_NAMES.map(([key]) => [key, NOOP_COUNTER]), + ) as unknown as Counters; +} + +export function getHistograms(): Histograms { + if (histograms) return histograms; + return Object.fromEntries( + HISTOGRAM_NAMES.map(([key]) => [key, NOOP_HISTOGRAM]), + ) as unknown as Histograms; +} + +export function initMetrics(getMeter?: (name: string) => Meter): { + counters: Counters; + histograms: Histograms; +} { + const meter = getMeter?.("agentmemory"); + + counters = Object.fromEntries( + COUNTER_NAMES.map(([key, name]) => [ + key, + meter ? meter.createCounter(name) : NOOP_COUNTER, + ]), + ) as unknown as Counters; + + histograms = Object.fromEntries( + HISTOGRAM_NAMES.map(([key, name]) => [ + key, + meter ? meter.createHistogram(name) : NOOP_HISTOGRAM, + ]), + ) as unknown as Histograms; + + return { counters, histograms }; +} diff --git a/src/triggers/api.ts b/src/triggers/api.ts new file mode 100644 index 0000000..7b6c2bf --- /dev/null +++ b/src/triggers/api.ts @@ -0,0 +1,3195 @@ +import { TriggerAction, type ISdk, type ApiRequest } from "iii-sdk"; +import type { Session, CompressedObservation, HookPayload, CommitLink, SessionSummary } from "../types.js"; +import { withKeyedLock } from "../state/keyed-mutex.js"; +import { KV } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { getLatestHealth } from "../health/monitor.js"; +import type { MetricsStore } from "../eval/metrics-store.js"; +import type { ResilientProvider } from "../providers/resilient.js"; +import { VERSION } from "../version.js"; +import { timingSafeCompare } from "../auth.js"; +import { isSlotsEnabled, isReflectEnabled } from "../functions/slots.js"; +import { renderViewerDocument } from "../viewer/document.js"; +import { getBoundViewerPort, getViewerSkipped } from "../viewer/server.js"; +import { MAX_FILES_UPPER_BOUND } from "../functions/replay.js"; +import { logger } from "../logger.js"; +import { + isGraphExtractionEnabled, + isConsolidationEnabled, + isAutoCompressEnabled, + isContextInjectionEnabled, + detectEmbeddingProvider, + detectLlmProviderKind, + getAgentId, + isAgentScopeIsolated, +} from "../config.js"; + +type Response = { + status_code: number; + headers?: Record; + body: unknown; +}; + +function parseOptionalInt(raw: unknown): number | undefined { + if (raw === undefined || raw === null || raw === "") return undefined; + const n = typeof raw === "number" ? raw : parseInt(String(raw), 10); + return Number.isFinite(n) ? n : undefined; +} + +function checkAuth( + req: ApiRequest, + secret: string | undefined, +): Response | null { + if (!secret) return null; + const auth = req.headers?.["authorization"] || req.headers?.["Authorization"]; + if ( + typeof auth !== "string" || + !timingSafeCompare(auth, `Bearer ${secret}`) + ) { + return { status_code: 401, body: { error: "unauthorized" } }; + } + return null; +} + +function requireConfiguredSecret( + secret: string | undefined, + feature: string, +): Response | null { + if (secret) return null; + return { + status_code: 503, + body: { error: `${feature} requires AGENTMEMORY_SECRET` }, + }; +} + +function flagDisabledResponse(opts: { + error: string; + flag: string; + enableHow: string; + docsHref: string; +}): Response { + return { + status_code: 503, + body: opts, + }; +} + +function graphDisabledResponse(): Response { + return flagDisabledResponse({ + error: "Knowledge graph not enabled", + flag: "GRAPH_EXTRACTION_ENABLED", + enableHow: "Set GRAPH_EXTRACTION_ENABLED=true and restart. Requires an LLM provider key.", + docsHref: "https://github.com/rohitg00/agentmemory#knowledge-graph", + }); +} + +function consolidationDisabledResponse(): Response { + return flagDisabledResponse({ + error: "Consolidation pipeline not enabled", + flag: "CONSOLIDATION_ENABLED", + enableHow: "Set CONSOLIDATION_ENABLED=true and restart. Requires an LLM provider key.", + docsHref: "https://github.com/rohitg00/agentmemory#consolidation", + }); +} + +function slotsDisabledResponse(): Response { + return flagDisabledResponse({ + error: "Memory slots not enabled", + flag: "AGENTMEMORY_SLOTS", + enableHow: "Set AGENTMEMORY_SLOTS=true (in ~/.agentmemory/.env or the shell) and restart.", + docsHref: "https://github.com/rohitg00/agentmemory#memory-slots", + }); +} + +function reflectDisabledResponse(): Response { + return flagDisabledResponse({ + error: "Slot reflection not enabled", + flag: "AGENTMEMORY_REFLECT", + enableHow: "Set AGENTMEMORY_REFLECT=true (in ~/.agentmemory/.env or the shell) and restart. Requires AGENTMEMORY_SLOTS=true.", + docsHref: "https://github.com/rohitg00/agentmemory#memory-slots", + }); +} + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function parseOptionalFiniteNumber(value: unknown): number | undefined | null { + if (value === undefined || value === null) return undefined; + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function parseOptionalPositiveInt(value: unknown): number | undefined | null { + const parsed = parseOptionalFiniteNumber(value); + if (parsed === undefined || parsed === null) return parsed; + if (!Number.isInteger(parsed) || parsed < 1) return null; + return parsed; +} + +export function registerApiTriggers( + sdk: ISdk, + kv: StateKV, + secret?: string, + metricsStore?: MetricsStore, + provider?: ResilientProvider | { circuitState?: unknown }, +): void { + sdk.registerFunction( + "middleware::api-auth", + async (input: { + request?: { headers?: Record }; + }) => { + if (!secret) return { action: "continue" }; + const headers = input?.request?.headers || {}; + const auth = headers["authorization"] || headers["Authorization"]; + if ( + typeof auth !== "string" || + !timingSafeCompare(auth, `Bearer ${secret}`) + ) { + return { + action: "respond", + response: { status_code: 401, body: { error: "unauthorized" } }, + }; + } + return { action: "continue" }; + }, + ); + + sdk.registerFunction("api::liveness", + async (): Promise => ({ + status_code: 200, + body: { status: "ok", service: "agentmemory", viewerPort: getBoundViewerPort(), viewerSkipped: getViewerSkipped() }, + }), + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::liveness", + config: { api_path: "/agentmemory/livez", http_method: "GET" }, + }); + + sdk.registerFunction("api::config-flags", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const providerKind = detectLlmProviderKind(); + const embeddingProvider = detectEmbeddingProvider() ? "embeddings" : "none"; + const flags = [ + { + key: "GRAPH_EXTRACTION_ENABLED", + label: "Knowledge graph extraction", + enabled: isGraphExtractionEnabled(), + default: false, + affects: ["Graph", "Dashboard"], + needsLlm: true, + description: "Extracts entities and relations from observations into a knowledge graph.", + enableHow: "Set GRAPH_EXTRACTION_ENABLED=true and provide an LLM key, then restart.", + docsHref: "https://github.com/rohitg00/agentmemory#knowledge-graph", + }, + { + key: "CONSOLIDATION_ENABLED", + label: "Memory consolidation", + enabled: isConsolidationEnabled(), + default: false, + affects: ["Dashboard", "Memories", "Crystals"], + needsLlm: true, + description: "Periodically summarizes sessions into semantic facts + procedures.", + enableHow: "Set CONSOLIDATION_ENABLED=true and provide an LLM key, then restart.", + docsHref: "https://github.com/rohitg00/agentmemory#consolidation", + }, + { + key: "AGENTMEMORY_AUTO_COMPRESS", + label: "LLM-powered observation compression", + enabled: isAutoCompressEnabled(), + default: false, + affects: ["Memories", "Timeline"], + needsLlm: true, + description: "Every observation is compressed by the LLM for richer summaries (costs tokens). OFF uses zero-LLM synthetic compression.", + enableHow: "Set AGENTMEMORY_AUTO_COMPRESS=true and provide an LLM key.", + docsHref: "https://github.com/rohitg00/agentmemory/issues/138", + }, + { + key: "AGENTMEMORY_INJECT_CONTEXT", + label: "In-conversation context injection", + enabled: isContextInjectionEnabled(), + default: false, + affects: ["Hooks"], + needsLlm: false, + description: "Hooks write recalled context into Claude Code's conversation. OFF captures in the background without injecting.", + enableHow: "Set AGENTMEMORY_INJECT_CONTEXT=true and restart.", + docsHref: "https://github.com/rohitg00/agentmemory/issues/143", + }, + ]; + return { + status_code: 200, + body: { + version: VERSION, + provider: providerKind, + embeddingProvider, + flags, + }, + }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::config-flags", + config: { + api_path: "/agentmemory/config/flags", + http_method: "GET", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::health", + async (req: ApiRequest): Promise => { + const health = await getLatestHealth(kv); + const functionMetrics = metricsStore ? await metricsStore.getAll() : []; + const circuitBreaker = + provider && "circuitState" in provider ? provider.circuitState : null; + + const status = health?.status || "healthy"; + const statusCode = status === "critical" ? 503 : 200; + + return { + status_code: statusCode, + body: { + status, + service: "agentmemory", + version: VERSION, + health: health || null, + functionMetrics, + circuitBreaker, + viewerPort: getBoundViewerPort(), + viewerSkipped: getViewerSkipped(), + }, + }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::health", + config: { + api_path: "/agentmemory/health", + http_method: "GET", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::observe", + async (req: ApiRequest): Promise => { + const body = (req.body ?? {}) as Record; + const hookType = asNonEmptyString(body.hookType); + const sessionId = asNonEmptyString(body.sessionId); + const project = asNonEmptyString(body.project); + const cwd = asNonEmptyString(body.cwd); + const timestamp = asNonEmptyString(body.timestamp); + if (!hookType || !sessionId || !project || !cwd || !timestamp) { + return { + status_code: 400, + body: { + error: + "hookType, sessionId, project, cwd, and timestamp are required strings", + }, + }; + } + const payload: HookPayload = { + hookType: hookType as HookPayload["hookType"], + sessionId, + project, + cwd, + timestamp, + data: body.data, + }; + const result = await sdk.trigger({ function_id: "mem::observe", payload }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::observe", + config: { + api_path: "/agentmemory/observe", + http_method: "POST", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::context", + async ( + req: ApiRequest<{ sessionId: string; project: string; budget?: number }>, + ): Promise => { + const body = (req.body ?? {}) as Record; + const sessionId = asNonEmptyString(body.sessionId); + const project = asNonEmptyString(body.project); + if (!sessionId || !project) { + return { + status_code: 400, + body: { error: "sessionId and project are required strings" }, + }; + } + const budget = parseOptionalPositiveInt(body.budget); + if (budget === null) { + return { + status_code: 400, + body: { error: "budget must be a positive integer" }, + }; + } + const payload: { sessionId: string; project: string; budget?: number } = { + sessionId, + project, + }; + if (budget !== undefined) payload.budget = budget; + const result = await sdk.trigger({ function_id: "mem::context", payload }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::context", + config: { + api_path: "/agentmemory/context", + http_method: "POST", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::search", + async ( + req: ApiRequest<{ + query: string; + limit?: number; + project?: string; + cwd?: string; + format?: string; + token_budget?: number; + agentId?: string; + }>, + ): Promise => { + const body = (req.body ?? {}) as Record; + const queryAgentId = + typeof (req as { query_params?: Record }) + .query_params?.["agentId"] === "string" + ? (req as { query_params: Record }) + .query_params["agentId"] + : undefined; + if (typeof body.query !== "string" || !body.query.trim()) { + return { status_code: 400, body: { error: "query is required and must be a non-empty string" } }; + } + if ( + body.limit !== undefined && + (!Number.isInteger(body.limit) || (body.limit as number) < 1) + ) { + return { status_code: 400, body: { error: "limit must be a positive integer" } }; + } + if (body.project !== undefined && typeof body.project !== "string") { + return { status_code: 400, body: { error: "project must be a string" } }; + } + if (body.cwd !== undefined && typeof body.cwd !== "string") { + return { status_code: 400, body: { error: "cwd must be a string" } }; + } + if ( + body.format !== undefined && + (typeof body.format !== "string" || + !["full", "compact", "narrative"].includes(body.format.trim().toLowerCase())) + ) { + return { + status_code: 400, + body: { error: "format must be one of: full, compact, narrative" }, + }; + } + if ( + body.token_budget !== undefined && + (!Number.isInteger(body.token_budget) || (body.token_budget as number) < 1) + ) { + return { + status_code: 400, + body: { error: "token_budget must be a positive integer" }, + }; + } + // #817: propagate agentId so the upstream isolation filter + // applies. Honors body.agentId (POST body), ?agentId=... query + // param, or implicit fallback to the worker's AGENT_ID when + // AGENTMEMORY_AGENT_SCOPE=isolated. + const bodyAgentId = + typeof body.agentId === "string" && body.agentId.trim().length > 0 + ? (body.agentId as string).trim() + : undefined; + const payload = { + query: body.query.trim(), + limit: body.limit as number | undefined, + project: body.project as string | undefined, + cwd: body.cwd as string | undefined, + format: + typeof body.format === "string" + ? body.format.trim().toLowerCase() + : undefined, + token_budget: body.token_budget as number | undefined, + agentId: bodyAgentId ?? queryAgentId, + }; + const result = await sdk.trigger({ function_id: "mem::search", payload: payload }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::search", + config: { + api_path: "/agentmemory/search", + http_method: "POST", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::compress-file", + async (req: ApiRequest<{ filePath: string }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const body = (req.body ?? {}) as Record; + const filePath = asNonEmptyString(body.filePath); + if (!filePath) { + return { + status_code: 400, + body: { error: "filePath is required and must be a non-empty string" }, + }; + } + const result = await sdk.trigger({ + function_id: "mem::compress-file", + payload: { filePath }, + }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::compress-file", + config: { api_path: "/agentmemory/compress-file", http_method: "POST" }, + }); + + sdk.registerFunction("api::replay::load", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const sessionId = asNonEmptyString(req.query_params?.["sessionId"]); + if (!sessionId) { + return { status_code: 400, body: { error: "sessionId is required" } }; + } + const result = await sdk.trigger({ + function_id: "mem::replay::load", + payload: { sessionId }, + }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::replay::load", + config: { api_path: "/agentmemory/replay/load", http_method: "GET" }, + }); + + sdk.registerFunction("api::replay::sessions", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const sessions = await kv.list(KV.sessions); + sessions.sort((a, b) => + (b.startedAt || "").localeCompare(a.startedAt || ""), + ); + return { status_code: 200, body: { success: true, sessions } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::replay::sessions", + config: { api_path: "/agentmemory/replay/sessions", http_method: "GET" }, + }); + + sdk.registerFunction("api::replay::import", + async ( + req: ApiRequest<{ path?: string; maxFiles?: number }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const body = (req.body ?? {}) as Record; + const payload: { path?: string; maxFiles?: number } = {}; + if (body.path !== undefined) { + if (typeof body.path !== "string" || body.path.trim().length === 0) { + return { + status_code: 400, + body: { error: "path must be a non-empty string" }, + }; + } + payload.path = body.path.trim(); + } + if (body.maxFiles !== undefined) { + const n = body.maxFiles as number; + if ( + !Number.isInteger(n) || + n < 1 || + n > MAX_FILES_UPPER_BOUND + ) { + return { + status_code: 400, + body: { + error: `maxFiles must be an integer between 1 and ${MAX_FILES_UPPER_BOUND}`, + }, + }; + } + payload.maxFiles = n; + } + const result = await sdk.trigger({ + function_id: "mem::replay::import-jsonl", + payload, + }); + return { status_code: 202, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::replay::import", + config: { api_path: "/agentmemory/replay/import-jsonl", http_method: "POST" }, + }); + + sdk.registerFunction("api::session::start", + async ( + req: ApiRequest<{ sessionId: string; project: string; cwd: string }>, + ): Promise => { + const body = (req.body ?? {}) as Record; + const sessionId = asNonEmptyString(body.sessionId); + const project = asNonEmptyString(body.project); + const cwd = asNonEmptyString(body.cwd); + if (!sessionId || !project || !cwd) { + return { + status_code: 400, + body: { + error: "sessionId, project, and cwd are required non-empty strings", + }, + }; + } + const title = typeof body.title === "string" ? body.title.trim() : undefined; + // allow session/start to override AGENT_ID from request body + // (multi-agent runtimes that route many roles through one server + // process). Falls back to the AGENT_ID env on the server. + const requestAgentId = + typeof body.agentId === "string" && body.agentId.trim().length > 0 + ? body.agentId.trim().slice(0, 128) + : undefined; + const agentId = requestAgentId ?? getAgentId(); + const session: Session = { + id: sessionId, + project, + cwd, + startedAt: new Date().toISOString(), + status: "active", + observationCount: 0, + ...(title ? { summary: title.slice(0, 200) } : {}), + ...(title ? { firstPrompt: title.slice(0, 200) } : {}), + ...(agentId ? { agentId } : {}), + }; + await kv.set(KV.sessions, sessionId, session); + const contextResult = await sdk.trigger< + { sessionId: string; project: string }, + { context: string } + >({ function_id: "mem::context", payload: { sessionId, project } }); + return { + status_code: 200, + body: { session, context: contextResult.context }, + }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::session::start", + config: { + api_path: "/agentmemory/session/start", + http_method: "POST", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::session::end", + async (req: ApiRequest<{ sessionId: string }>): Promise => { + const sessionId = asNonEmptyString((req.body as Record)?.sessionId); + if (!sessionId) { + return { + status_code: 400, + body: { error: "sessionId is required and must be a non-empty string" }, + }; + } + await kv.update(KV.sessions, sessionId, [ + { type: "set", path: "endedAt", value: new Date().toISOString() }, + { type: "set", path: "status", value: "completed" }, + ]); + // Fan out session-stopped lifecycle (non-blocking). + try { + sdk.trigger({ + function_id: "event::session::stopped", + payload: { sessionId }, + action: TriggerAction.Void(), + }); + } catch (err) { + logger.warn("event::session::stopped trigger failed", { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + return { status_code: 200, body: { success: true } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::session::end", + config: { + api_path: "/agentmemory/session/end", + http_method: "POST", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::summarize", + async (req: ApiRequest<{ sessionId: string }>): Promise => { + const sessionId = asNonEmptyString((req.body as Record)?.sessionId); + if (!sessionId) { + return { status_code: 400, body: { error: "sessionId is required" } }; + } + const result = await sdk.trigger({ + function_id: "mem::summarize", + payload: { sessionId }, + }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::summarize", + config: { + api_path: "/agentmemory/summarize", + http_method: "POST", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::session::commit", + async (req: ApiRequest): Promise => { + const body = (req.body ?? {}) as Record; + const sha = asNonEmptyString(body.sha); + if (!sha) { + return { + status_code: 400, + body: { error: "sha is required and must be a non-empty string" }, + }; + } + const sessionId = asNonEmptyString(body.sessionId) ?? undefined; + const branch = asNonEmptyString(body.branch) ?? undefined; + const repo = asNonEmptyString(body.repo) ?? undefined; + const message = asNonEmptyString(body.message) ?? undefined; + const author = asNonEmptyString(body.author) ?? undefined; + const authoredAt = asNonEmptyString(body.authoredAt) ?? undefined; + const files = Array.isArray(body.files) + ? (body.files as unknown[]).filter( + (f): f is string => typeof f === "string" && f.length > 0, + ) + : undefined; + + const link = await withKeyedLock(`commit:${sha}`, async () => { + const existing = await kv.get(KV.commits, sha); + const sessionSet = new Set(existing?.sessionIds ?? []); + if (sessionId) sessionSet.add(sessionId); + const merged: CommitLink = { + sha, + shortSha: existing?.shortSha ?? sha.slice(0, 7), + branch: branch ?? existing?.branch, + repo: repo ?? existing?.repo, + message: message ?? existing?.message, + author: author ?? existing?.author, + authoredAt: authoredAt ?? existing?.authoredAt, + files: files ?? existing?.files, + sessionIds: Array.from(sessionSet), + linkedAt: existing?.linkedAt ?? new Date().toISOString(), + }; + await kv.set(KV.commits, sha, merged); + return merged; + }); + + if (sessionId) { + await withKeyedLock(`session:${sessionId}`, async () => { + const session = await kv.get(KV.sessions, sessionId); + if (!session) return; + const shaSet = new Set(session.commitShas ?? []); + shaSet.add(sha); + session.commitShas = Array.from(shaSet); + await kv.set(KV.sessions, sessionId, session); + }); + } + + return { status_code: 200, body: { commit: link } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::session::commit", + config: { + api_path: "/agentmemory/session/commit", + http_method: "POST", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::session::by-commit", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const sha = asNonEmptyString(req.query_params?.["sha"]); + if (!sha) { + return { + status_code: 400, + body: { error: "sha is required and must be a non-empty string" }, + }; + } + const link = await kv.get(KV.commits, sha); + if (!link) { + return { + status_code: 404, + body: { error: "no sessions linked to this commit" }, + }; + } + const fetched = await Promise.all( + (link.sessionIds ?? []).map((sid) => kv.get(KV.sessions, sid)), + ); + const sessions = fetched.filter((s): s is Session => s !== null); + return { status_code: 200, body: { commit: link, sessions } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::session::by-commit", + config: { + api_path: "/agentmemory/session/by-commit", + http_method: "GET", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::commits", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const branch = asNonEmptyString(req.query_params?.["branch"]); + const repo = asNonEmptyString(req.query_params?.["repo"]); + const rawLimit = parseOptionalInt(req.query_params?.["limit"]); + const limit = Math.max(1, Math.min(500, rawLimit ?? 100)); + const all = await kv.list(KV.commits); + const filtered = all + .filter((c) => !branch || c.branch === branch) + .filter((c) => !repo || c.repo === repo) + .sort((a, b) => ((a.linkedAt ?? "") < (b.linkedAt ?? "") ? 1 : -1)) + .slice(0, limit); + return { status_code: 200, body: { commits: filtered } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::commits", + config: { + api_path: "/agentmemory/commits", + http_method: "GET", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::sessions", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const sessions = await kv.list(KV.sessions); + const normalizedAgentId = + typeof req.query_params?.["agentId"] === "string" + ? req.query_params["agentId"].trim() + : undefined; + const wildcardAgent = normalizedAgentId === "*"; + const explicitAgentId = + normalizedAgentId && !wildcardAgent ? normalizedAgentId : undefined; + const filterAgentId = wildcardAgent + ? undefined + : explicitAgentId ?? + (isAgentScopeIsolated() ? getAgentId() : undefined); + const filtered = filterAgentId + ? sessions.filter((s) => s.agentId === filterAgentId) + : sessions; + const summaries = await Promise.all( + filtered.map((s) => + kv.get(KV.summaries, s.id).catch(() => null), + ), + ); + const withSummary = filtered.map((s, i) => + summaries[i] ? { ...s, summary: summaries[i] } : s, + ); + return { status_code: 200, body: { sessions: withSummary } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::sessions", + config: { api_path: "/agentmemory/sessions", http_method: "GET" }, + }); + + sdk.registerFunction("api::observations", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const sessionId = asNonEmptyString(req.query_params?.["sessionId"]); + if (!sessionId) + return { status_code: 400, body: { error: "sessionId required" } }; + const observations = await kv.list( + KV.observations(sessionId), + ); + const normalizedAgentId = + typeof req.query_params?.["agentId"] === "string" + ? req.query_params["agentId"].trim() + : undefined; + const wildcardAgent = normalizedAgentId === "*"; + const explicitAgentId = + normalizedAgentId && !wildcardAgent ? normalizedAgentId : undefined; + const filterAgentId = wildcardAgent + ? undefined + : explicitAgentId ?? + (isAgentScopeIsolated() ? getAgentId() : undefined); + const filtered = filterAgentId + ? observations.filter((o) => o.agentId === filterAgentId) + : observations; + return { status_code: 200, body: { observations: filtered } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::observations", + config: { api_path: "/agentmemory/observations", http_method: "GET" }, + }); + + sdk.registerFunction("api::file-context", + async ( + req: ApiRequest<{ sessionId: string; files: string[] }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::file-context", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::file-context", + config: { api_path: "/agentmemory/file-context", http_method: "POST" }, + }); + + sdk.registerFunction("api::enrich", + async ( + req: ApiRequest<{ + sessionId: string; + files: string[]; + terms?: string[]; + toolName?: string; + project?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if ( + !req.body?.sessionId || + typeof req.body.sessionId !== "string" || + !Array.isArray(req.body?.files) || + req.body.files.length === 0 || + !req.body.files.every((f: unknown) => typeof f === "string") + ) { + return { + status_code: 400, + body: { + error: "sessionId (string) and files (string[]) are required", + }, + }; + } + if ( + req.body.terms !== undefined && + (!Array.isArray(req.body.terms) || + !req.body.terms.every((t: unknown) => typeof t === "string")) + ) { + return { + status_code: 400, + body: { error: "terms must be an array of strings" }, + }; + } + if ( + req.body.project !== undefined && + (typeof req.body.project !== "string" || !req.body.project.trim()) + ) { + return { + status_code: 400, + body: { error: "project must be a non-empty string" }, + }; + } + const result = await sdk.trigger({ + function_id: "mem::enrich", + payload: { + sessionId: req.body.sessionId, + files: req.body.files, + ...(req.body.terms !== undefined && { terms: req.body.terms }), + ...(req.body.toolName !== undefined && { toolName: req.body.toolName }), + ...(req.body.project !== undefined && { project: req.body.project }), + }, + }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::enrich", + config: { api_path: "/agentmemory/enrich", http_method: "POST" }, + }); + + sdk.registerFunction("api::remember", + async ( + req: ApiRequest<{ + content: string; + type?: string; + concepts?: string[]; + files?: string[]; + ttlDays?: number; + sourceObservationIds?: string[]; + project?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if ( + !req.body?.content || + typeof req.body.content !== "string" || + !req.body.content.trim() + ) { + return { status_code: 400, body: { error: "content is required" } }; + } + if ( + req.body.project !== undefined && + (typeof req.body.project !== "string" || !req.body.project.trim()) + ) { + return { status_code: 400, body: { error: "project must be a non-empty string" } }; + } + const result = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: req.body.content, + ...(req.body.type !== undefined && { type: req.body.type }), + ...(req.body.concepts !== undefined && { concepts: req.body.concepts }), + ...(req.body.files !== undefined && { files: req.body.files }), + ...(req.body.ttlDays !== undefined && { ttlDays: req.body.ttlDays }), + ...(req.body.sourceObservationIds !== undefined && { sourceObservationIds: req.body.sourceObservationIds }), + ...(req.body.project !== undefined && { project: req.body.project }), + }, + }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::remember", + config: { api_path: "/agentmemory/remember", http_method: "POST" }, + }); + + sdk.registerFunction("api::forget", + async ( + req: ApiRequest<{ + sessionId?: string; + observationIds?: string[]; + memoryId?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.sessionId && !req.body?.memoryId) { + return { + status_code: 400, + body: { error: "sessionId or memoryId is required" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::forget", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::forget", + config: { api_path: "/agentmemory/forget", http_method: "POST" }, + }); + + sdk.registerFunction("api::consolidate", + async ( + req: ApiRequest<{ project?: string; minObservations?: number }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::consolidate", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::consolidate", + config: { api_path: "/agentmemory/consolidate", http_method: "POST" }, + }); + + sdk.registerFunction("api::patterns", + async (req: ApiRequest<{ project?: string }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::patterns", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::patterns", + config: { api_path: "/agentmemory/patterns", http_method: "POST" }, + }); + + sdk.registerFunction("api::generate-rules", + async (req: ApiRequest<{ project?: string }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::generate-rules", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::generate-rules", + config: { api_path: "/agentmemory/generate-rules", http_method: "POST" }, + }); + + sdk.registerFunction("api::migrate", + async ( + req: ApiRequest<{ dbPath?: string; step?: string; dryRun?: boolean }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const hasStep = + typeof req.body?.step === "string" && req.body.step.trim().length > 0; + const hasDbPath = + typeof req.body?.dbPath === "string" && req.body.dbPath.trim().length > 0; + if (!hasStep && !hasDbPath) { + return { + status_code: 400, + body: { error: "Either step (string) or dbPath (string) is required" }, + }; + } + const result = await sdk.trigger({ + function_id: "mem::migrate", + payload: { + ...(req.body.step !== undefined && { step: req.body.step }), + ...(req.body.dbPath !== undefined && { dbPath: req.body.dbPath }), + ...(req.body.dryRun !== undefined && { dryRun: req.body.dryRun }), + }, + }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::migrate", + config: { api_path: "/agentmemory/migrate", http_method: "POST" }, + }); + + sdk.registerFunction("api::evict", + async (req: ApiRequest<{ dryRun?: boolean }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const dryRun = + req.query_params?.["dryRun"] === "true" || req.body?.dryRun === true; + const result = await sdk.trigger({ function_id: "mem::evict", payload: { dryRun } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::evict", + config: { api_path: "/agentmemory/evict", http_method: "POST" }, + }); + + sdk.registerFunction("api::smart-search", + async ( + req: ApiRequest<{ + query?: string; + expandIds?: Array; + limit?: number; + project?: string; + includeLessons?: boolean; + agentId?: string; + sessionId?: string; + source?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if ( + !req.body?.query && + (!req.body?.expandIds || req.body.expandIds.length === 0) + ) { + return { + status_code: 400, + body: { error: "query or expandIds is required" }, + }; + } + // #771: route the X-Agentmemory-Source header into the payload so + // the followup-rate diagnostic can skip viewer-originated calls. + // Body wins if both are set (advanced callers explicitly override). + const headers = (req.headers || {}) as Record; + const sourceHeader = headers["x-agentmemory-source"] ?? headers["X-Agentmemory-Source"]; + const sourceFromHeader = Array.isArray(sourceHeader) ? sourceHeader[0] : sourceHeader; + // Whitelist payload fields explicitly — REST endpoints never pass + // the raw request body through to sdk.trigger (AGENTS.md security + // section). Drops unknown fields so a misbehaving client can't + // inject downstream-only options. + const payload = { + query: req.body?.query, + expandIds: req.body?.expandIds, + limit: req.body?.limit, + project: req.body?.project, + includeLessons: req.body?.includeLessons, + agentId: req.body?.agentId, + sessionId: req.body?.sessionId, + source: req.body?.source ?? sourceFromHeader, + }; + const result = await sdk.trigger({ function_id: "mem::smart-search", payload }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::smart-search", + config: { api_path: "/agentmemory/smart-search", http_method: "POST" }, + }); + + // #771: read-back endpoint for the followup-rate diagnostic. Returns + // a directional signal — overcounts on legitimate query refinement — + // so help text + the CLI status line carry the same caveat. + sdk.registerFunction("api::diagnostic-followup", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ + function_id: "mem::diagnostic::followup-stats", + payload: {}, + }); + return { + status_code: 200, + body: { + ...(result as Record), + caveat: + "Directional signal: overcounts on legitimate query refinement. " + + "Tune via AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS.", + }, + }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::diagnostic-followup", + config: { + api_path: "/agentmemory/diagnostics/followup", + http_method: "GET", + middleware_function_ids: ["middleware::api-auth"], + }, + }); + + sdk.registerFunction("api::timeline", + async ( + req: ApiRequest<{ + anchor: string; + project?: string; + before?: number; + after?: number; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.anchor) { + return { status_code: 400, body: { error: "anchor is required" } }; + } + const result = await sdk.trigger({ function_id: "mem::timeline", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::timeline", + config: { api_path: "/agentmemory/timeline", http_method: "POST" }, + }); + + sdk.registerFunction("api::profile", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const project = req.query_params["project"] as string; + if (!project) { + return { + status_code: 400, + body: { error: "project query param is required" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::profile", payload: { project } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::profile", + config: { api_path: "/agentmemory/profile", http_method: "GET" }, + }); + + sdk.registerFunction("api::export", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + // mem::export already supports maxSessions/offset internally, + // but the HTTP endpoint hardcoded an empty payload — so /export on a + // real corpus (40 sessions × 34K observations × 8K memories) hit the + // iii engine invocation timeout and `agentmemory status` reported 0. + // Pass through the query-string pagination so callers can chunk. + const rawMax = req.query_params?.["maxSessions"]; + const rawOffset = req.query_params?.["offset"]; + const payload: { maxSessions?: number; offset?: number } = {}; + if (typeof rawMax === "string") { + const n = Number(rawMax); + if (Number.isInteger(n) && n > 0) payload.maxSessions = n; + } + if (typeof rawOffset === "string") { + const n = Number(rawOffset); + if (Number.isInteger(n) && n >= 0) payload.offset = n; + } + const result = await sdk.trigger({ + function_id: "mem::export", + payload, + }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::export", + config: { api_path: "/agentmemory/export", http_method: "GET" }, + }); + + sdk.registerFunction("api::import", + async ( + req: ApiRequest<{ + exportData: unknown; + strategy?: "merge" | "replace" | "skip"; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.exportData) { + return { status_code: 400, body: { error: "exportData is required" } }; + } + const result = await sdk.trigger({ function_id: "mem::import", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::import", + config: { api_path: "/agentmemory/import", http_method: "POST" }, + }); + + sdk.registerFunction("api::relations", + async ( + req: ApiRequest<{ sourceId: string; targetId: string; type: string }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.sourceId || !req.body?.targetId || !req.body?.type) { + return { + status_code: 400, + body: { error: "sourceId, targetId, and type are required" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::relate", payload: req.body }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::relations", + config: { api_path: "/agentmemory/relations", http_method: "POST" }, + }); + + sdk.registerFunction("api::evolve", + async ( + req: ApiRequest<{ + memoryId: string; + newContent: string; + newTitle?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.memoryId || !req.body?.newContent) { + return { + status_code: 400, + body: { error: "memoryId and newContent are required" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::evolve", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::evolve", + config: { api_path: "/agentmemory/evolve", http_method: "POST" }, + }); + + sdk.registerFunction("api::auto-forget", + async (req: ApiRequest<{ dryRun?: boolean }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const dryRun = + req.query_params?.["dryRun"] === "true" || req.body?.dryRun === true; + const result = await sdk.trigger({ function_id: "mem::auto-forget", payload: { dryRun } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::auto-forget", + config: { api_path: "/agentmemory/auto-forget", http_method: "POST" }, + }); + + sdk.registerFunction("api::claude-bridge-read", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ function_id: "mem::claude-bridge-read", payload: {} }); + return { status_code: 200, body: result }; + } catch { + return { + status_code: 404, + body: { error: "Claude bridge not enabled" }, + }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::claude-bridge-read", + config: { api_path: "/agentmemory/claude-bridge/read", http_method: "GET" }, + }); + + sdk.registerFunction("api::claude-bridge-sync", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ function_id: "mem::claude-bridge-sync", payload: {} }); + return { status_code: 200, body: result }; + } catch { + return { + status_code: 404, + body: { error: "Claude bridge not enabled" }, + }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::claude-bridge-sync", + config: { + api_path: "/agentmemory/claude-bridge/sync", + http_method: "POST", + }, + }); + + sdk.registerFunction("api::graph-query", + async ( + req: ApiRequest<{ + startNodeId?: string; + nodeType?: string; + maxDepth?: number; + query?: string; + limit?: number; + offset?: number; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + // Whitelist payload fields explicitly; AGENTS.md security rule: + // REST endpoints never pass raw req.body through to sdk.trigger. + const payload = { + startNodeId: req.body?.startNodeId, + nodeType: req.body?.nodeType, + maxDepth: req.body?.maxDepth, + query: req.body?.query, + limit: req.body?.limit, + offset: req.body?.offset, + }; + try { + const result = await sdk.trigger({ function_id: "mem::graph-query", payload }); + return { status_code: 200, body: result }; + } catch { + return graphDisabledResponse(); + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::graph-query", + config: { api_path: "/agentmemory/graph/query", http_method: "POST" }, + }); + + sdk.registerFunction("api::graph-stats", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ function_id: "mem::graph-stats", payload: {} }); + return { status_code: 200, body: result }; + } catch { + return graphDisabledResponse(); + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::graph-stats", + config: { api_path: "/agentmemory/graph/stats", http_method: "GET" }, + }); + + // #814: explicit snapshot rebuild endpoint. Pays the full graph + // enumeration once and persists a top-degree subgraph + aggregate + // counts so subsequent /graph/query and /graph/stats calls skip the + // unbounded kv.list. Operator-grade endpoint exposed for the viewer + // banner action and CLI repair. + sdk.registerFunction("api::graph-snapshot-rebuild", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ + function_id: "mem::graph-snapshot-rebuild", + payload: {}, + }); + return { status_code: 200, body: result }; + } catch { + return graphDisabledResponse(); + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::graph-snapshot-rebuild", + config: { api_path: "/agentmemory/graph/snapshot-rebuild", http_method: "POST" }, + }); + + // #814 v2: clean-restart endpoint for legacy corpora too large for + // safe rebuild. Wipes graph state without touching observations, so + // recall + history stay intact while the graph rebuilds incrementally + // from new extracts (or a one-shot /graph/build replay). + sdk.registerFunction("api::graph-reset", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ + function_id: "mem::graph-reset", + payload: {}, + }); + return { status_code: 200, body: result }; + } catch { + return graphDisabledResponse(); + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::graph-reset", + config: { api_path: "/agentmemory/graph/reset", http_method: "POST" }, + }); + + sdk.registerFunction("api::graph-extract", + async (req: ApiRequest<{ observations: unknown[] }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if ( + !Array.isArray(req.body?.observations) || + req.body.observations.length === 0 + ) { + return { + status_code: 400, + body: { error: "observations array is required" }, + }; + } + try { + const result = await sdk.trigger({ function_id: "mem::graph-extract", payload: req.body }); + return { status_code: 200, body: result }; + } catch { + return graphDisabledResponse(); + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::graph-extract", + config: { api_path: "/agentmemory/graph/extract", http_method: "POST" }, + }); + + // Backfill the knowledge graph from existing compressed observations. + // Viewer calls this when the graph is empty (#666). Iterates every + // session, collects observations that have a `title` (compressed only), + // and feeds them through `mem::graph-extract` in batches. + sdk.registerFunction("api::graph-build", + async (req: ApiRequest<{ batchSize?: number }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const batchSize = Math.max( + 1, + Math.min(100, Number((req.body as { batchSize?: number })?.batchSize) || 25), + ); + try { + const sessions = await kv.list(KV.sessions); + let totalNodes = 0; + let totalEdges = 0; + let batchesRun = 0; + for (const session of sessions) { + const sid = session?.id; + if (typeof sid !== "string" || sid.length === 0) continue; + const observations = await kv.list(KV.observations(sid)); + const compressed = observations.filter((o) => o && typeof o.title === "string" && o.title.length > 0); + if (compressed.length === 0) continue; + for (let i = 0; i < compressed.length; i += batchSize) { + const batch = compressed.slice(i, i + batchSize); + try { + const result = (await sdk.trigger({ + function_id: "mem::graph-extract", + payload: { observations: batch }, + })) as { success?: boolean; nodesAdded?: number; edgesAdded?: number }; + if (result?.success) { + totalNodes += Number(result.nodesAdded) || 0; + totalEdges += Number(result.edgesAdded) || 0; + } + batchesRun++; + } catch (err) { + logger.warn("graph-build batch failed", { + sessionId: sid, + batchIndex: Math.floor(i / batchSize), + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + return { + status_code: 200, + body: { + success: true, + sessions: sessions.length, + batches: batchesRun, + nodes: totalNodes, + edges: totalEdges, + }, + }; + } catch { + return graphDisabledResponse(); + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::graph-build", + config: { api_path: "/agentmemory/graph/build", http_method: "POST" }, + }); + + sdk.registerFunction("api::consolidate-pipeline", + async (req: ApiRequest<{ tier?: string }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ function_id: "mem::consolidate-pipeline", payload: req.body || {}, + }); + return { status_code: 200, body: result }; + } catch { + return consolidationDisabledResponse(); + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::consolidate-pipeline", + config: { + api_path: "/agentmemory/consolidate-pipeline", + http_method: "POST", + }, + }); + + sdk.registerFunction("api::team-share", + async ( + req: ApiRequest<{ itemId: string; itemType: string; project?: string }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.itemId || !req.body?.itemType) { + return { + status_code: 400, + body: { error: "itemId and itemType are required" }, + }; + } + try { + const result = await sdk.trigger({ function_id: "mem::team-share", payload: req.body }); + return { status_code: 201, body: result }; + } catch { + return { status_code: 404, body: { error: "Team memory not enabled" } }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::team-share", + config: { api_path: "/agentmemory/team/share", http_method: "POST" }, + }); + + sdk.registerFunction("api::team-feed", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const parsedLimit = parseOptionalInt(req.query_params?.["limit"]); + const limit = parsedLimit ?? 20; + const result = await sdk.trigger({ function_id: "mem::team-feed", payload: { limit } }); + return { status_code: 200, body: result }; + } catch { + return { status_code: 404, body: { error: "Team memory not enabled" } }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::team-feed", + config: { api_path: "/agentmemory/team/feed", http_method: "GET" }, + }); + + sdk.registerFunction("api::team-profile", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ function_id: "mem::team-profile", payload: {} }); + return { status_code: 200, body: result }; + } catch { + return { status_code: 404, body: { error: "Team memory not enabled" } }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::team-profile", + config: { api_path: "/agentmemory/team/profile", http_method: "GET" }, + }); + + sdk.registerFunction("api::audit", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const parsedLimit = parseOptionalInt(req.query_params?.["limit"]); + const entries = await sdk.trigger({ function_id: "mem::audit-query", payload: { + operation: req.query_params?.["operation"], + limit: parsedLimit ?? 50, + } }); + return { status_code: 200, body: { entries, success: true } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::audit", + config: { api_path: "/agentmemory/audit", http_method: "GET" }, + }); + + sdk.registerFunction("api::governance-delete", + async ( + req: ApiRequest<{ memoryIds: string[]; reason?: string }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.memoryIds || !Array.isArray(req.body.memoryIds)) { + return { + status_code: 400, + body: { error: "memoryIds array is required" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::governance-delete", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::governance-delete", + config: { + api_path: "/agentmemory/governance/memories", + http_method: "DELETE", + }, + }); + + sdk.registerFunction("api::governance-bulk", + async ( + req: ApiRequest<{ + type?: string[]; + dateFrom?: string; + dateTo?: string; + qualityBelow?: number; + dryRun?: boolean; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::governance-bulk", payload: req.body || {} }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::governance-bulk", + config: { + api_path: "/agentmemory/governance/bulk-delete", + http_method: "POST", + }, + }); + + sdk.registerFunction("api::snapshots", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ function_id: "mem::snapshot-list", payload: {} }); + return { status_code: 200, body: result }; + } catch { + return { status_code: 404, body: { error: "Snapshots not enabled" } }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::snapshots", + config: { api_path: "/agentmemory/snapshots", http_method: "GET" }, + }); + + sdk.registerFunction("api::snapshot-create", + async (req: ApiRequest<{ message?: string }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ function_id: "mem::snapshot-create", payload: req.body || {}, + }); + return { status_code: 201, body: result }; + } catch { + return { status_code: 404, body: { error: "Snapshots not enabled" } }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::snapshot-create", + config: { api_path: "/agentmemory/snapshot/create", http_method: "POST" }, + }); + + sdk.registerFunction("api::snapshot-restore", + async (req: ApiRequest<{ commitHash: string }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.commitHash) { + return { status_code: 400, body: { error: "commitHash is required" } }; + } + try { + const result = await sdk.trigger({ function_id: "mem::snapshot-restore", payload: req.body }); + return { status_code: 200, body: result }; + } catch { + return { status_code: 404, body: { error: "Snapshots not enabled" } }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::snapshot-restore", + config: { api_path: "/agentmemory/snapshot/restore", http_method: "POST" }, + }); + + sdk.registerFunction("api::memories", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const memories = await kv.list(KV.memories); + const latest = req.query_params?.["latest"] === "true"; + // agentId filter. Request param wins, env AGENT_ID (when + // scope=isolated) is the fallback. Shared mode keeps the tag but + // does not restrict the list endpoint. Pass agentId=* to opt out + // of the env scope entirely. includeOrphans=true surfaces + // pre-AGENT_ID memories whose agentId is undefined. + const normalizedAgentId = + typeof req.query_params?.["agentId"] === "string" + ? req.query_params["agentId"].trim() + : undefined; + const wildcardAgent = normalizedAgentId === "*"; + const explicitAgentId = + normalizedAgentId && !wildcardAgent ? normalizedAgentId : undefined; + const includeOrphans = + req.query_params?.["includeOrphans"] === "true"; + const filterAgentId = wildcardAgent + ? undefined + : explicitAgentId ?? (isAgentScopeIsolated() ? getAgentId() : undefined); + let filtered = latest ? memories.filter((m) => m.isLatest) : memories; + if (filterAgentId) { + filtered = filtered.filter( + (m) => + m.agentId === filterAgentId || + (includeOrphans && m.agentId === undefined), + ); + } + + // viewer + `agentmemory status` were hitting this endpoint to + // count memories. On a real corpus (8K+ memories) the unbounded + // response either timed out at the iii engine boundary ("Invocation + // stopped") or arrived too large for the viewer to render — so the + // UI showed 0 memories despite a healthy store. Two opt-in modes: + // ?count=true — totals only, no payload + // ?limit=N&offset=M — page slice (default unlimited for back-compat) + if (req.query_params?.["count"] === "true") { + // Match the SAME scope that the list path applies — returning + // unfiltered totals here would leak cross-agent counts to a + // caller that's blocked from the underlying rows. + return { + status_code: 200, + body: { + total: filtered.length, + latestCount: filtered.filter((m) => m.isLatest).length, + }, + }; + } + + const rawLimit = req.query_params?.["limit"]; + const rawOffset = req.query_params?.["offset"]; + const parsedLimit = + typeof rawLimit === "string" ? Number(rawLimit) : Number.NaN; + const parsedOffset = + typeof rawOffset === "string" ? Number(rawOffset) : Number.NaN; + const limit = + Number.isInteger(parsedLimit) && parsedLimit > 0 + ? Math.min(parsedLimit, 5000) + : undefined; + const offset = + Number.isInteger(parsedOffset) && parsedOffset >= 0 ? parsedOffset : 0; + const sliced = + limit !== undefined ? filtered.slice(offset, offset + limit) : filtered; + + return { + status_code: 200, + body: { + memories: sliced, + total: filtered.length, + offset, + limit: limit ?? null, + }, + }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::memories", + config: { api_path: "/agentmemory/memories", http_method: "GET" }, + }); + + sdk.registerFunction("api::memory-by-id", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const id = req.path_params?.["id"]; + if (!id || typeof id !== "string") { + return { status_code: 400, body: { error: "id path parameter is required" } }; + } + const memory = await kv.get(KV.memories, id); + if (!memory) { + return { status_code: 404, body: { error: `memory not found: ${id}` } }; + } + return { status_code: 200, body: { memory } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::memory-by-id", + config: { api_path: "/agentmemory/memories/:id", http_method: "GET" }, + }); + + sdk.registerFunction("api::semantic-list", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const semantic = await kv.list(KV.semantic); + return { status_code: 200, body: { semantic } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::semantic-list", + config: { api_path: "/agentmemory/semantic", http_method: "GET" }, + }); + + sdk.registerFunction("api::procedural-list", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const procedural = await kv.list(KV.procedural); + return { status_code: 200, body: { procedural } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::procedural-list", + config: { api_path: "/agentmemory/procedural", http_method: "GET" }, + }); + + sdk.registerFunction("api::relations-list", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const relations = await kv.list(KV.relations); + return { status_code: 200, body: { relations } }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::relations-list", + config: { api_path: "/agentmemory/relations", http_method: "GET" }, + }); + + sdk.registerFunction("api::vision-search", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const body = (req.body ?? {}) as Record; + const queryText = asNonEmptyString(body["queryText"]); + const queryImageRef = asNonEmptyString(body["queryImageRef"]); + const queryImageBase64 = asNonEmptyString(body["queryImageBase64"]); + const sessionId = asNonEmptyString(body["sessionId"]); + if (!queryText && !queryImageRef && !queryImageBase64) { + return { + status_code: 400, + body: { error: "queryText, queryImageRef, or queryImageBase64 required" }, + }; + } + const topKParsed = parseOptionalPositiveInt(body["topK"]); + if (topKParsed === null) { + return { status_code: 400, body: { error: "topK must be a positive integer" } }; + } + const payload: Record = {}; + if (queryText) payload["queryText"] = queryText; + if (queryImageRef) payload["queryImageRef"] = queryImageRef; + if (queryImageBase64) payload["queryImageBase64"] = queryImageBase64; + if (sessionId) payload["sessionId"] = sessionId; + if (topKParsed !== undefined) payload["topK"] = Math.min(50, topKParsed); + const result = await sdk.trigger({ function_id: "mem::vision-search", payload }); + const resp = result as { success?: boolean; error?: string }; + if (resp?.success === false) { + return { status_code: resp.error?.includes("disabled") ? 503 : 400, body: resp }; + } + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::vision-search", + config: { api_path: "/agentmemory/vision-search", http_method: "POST" }, + }); + + sdk.registerFunction("api::vision-embed", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const body = (req.body ?? {}) as Record; + const imageRef = asNonEmptyString(body["imageRef"]); + const sessionId = asNonEmptyString(body["sessionId"]); + const observationId = asNonEmptyString(body["observationId"]); + if (!imageRef) { + return { status_code: 400, body: { error: "imageRef is required" } }; + } + const payload: Record = { imageRef }; + if (sessionId) payload["sessionId"] = sessionId; + if (observationId) payload["observationId"] = observationId; + const result = await sdk.trigger({ function_id: "mem::vision-embed", payload }); + const resp = result as { success?: boolean; error?: string }; + if (resp?.success === false) { + return { status_code: resp.error?.includes("disabled") ? 503 : 400, body: resp }; + } + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::vision-embed", + config: { api_path: "/agentmemory/vision-embed", http_method: "POST" }, + }); + + sdk.registerFunction("api::slot-list", async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!isSlotsEnabled()) return slotsDisabledResponse(); + const result = await sdk.trigger({ function_id: "mem::slot-list", payload: {} }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ + type: "http", + function_id: "api::slot-list", + config: { api_path: "/agentmemory/slots", http_method: "GET" }, + }); + + sdk.registerFunction("api::slot-get", async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!isSlotsEnabled()) return slotsDisabledResponse(); + const label = asNonEmptyString(req.query_params?.["label"]); + if (!label) return { status_code: 400, body: { error: "label query param required" } }; + const result = await sdk.trigger({ function_id: "mem::slot-get", payload: { label } }); + const resp = result as { success?: boolean; error?: string }; + if (resp?.success === false) { + return { status_code: resp.error?.includes("not found") ? 404 : 400, body: resp }; + } + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ + type: "http", + function_id: "api::slot-get", + config: { api_path: "/agentmemory/slot", http_method: "GET" }, + }); + + sdk.registerFunction("api::slot-create", async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!isSlotsEnabled()) return slotsDisabledResponse(); + const body = (req.body ?? {}) as Record; + const label = asNonEmptyString(body["label"]); + if (!label) return { status_code: 400, body: { error: "label required" } }; + // Reject malformed inputs instead of silently dropping them. + if (body["content"] !== undefined && typeof body["content"] !== "string") { + return { status_code: 400, body: { error: "content must be a string" } }; + } + if (body["description"] !== undefined && typeof body["description"] !== "string") { + return { status_code: 400, body: { error: "description must be a string" } }; + } + if (body["pinned"] !== undefined && typeof body["pinned"] !== "boolean") { + return { status_code: 400, body: { error: "pinned must be a boolean" } }; + } + if ( + body["scope"] !== undefined && + body["scope"] !== "project" && + body["scope"] !== "global" + ) { + return { status_code: 400, body: { error: "scope must be 'project' or 'global'" } }; + } + const sizeLimit = parseOptionalPositiveInt(body["sizeLimit"]); + if (sizeLimit === null) { + return { status_code: 400, body: { error: "sizeLimit must be a positive integer" } }; + } + if (sizeLimit !== undefined && sizeLimit > 20000) { + return { status_code: 400, body: { error: "sizeLimit must be <= 20000" } }; + } + const payload: Record = { label }; + if (typeof body["content"] === "string") payload["content"] = body["content"]; + if (typeof body["description"] === "string") payload["description"] = body["description"]; + if (sizeLimit !== undefined) payload["sizeLimit"] = sizeLimit; + if (typeof body["pinned"] === "boolean") payload["pinned"] = body["pinned"]; + if (body["scope"] === "project" || body["scope"] === "global") payload["scope"] = body["scope"]; + const result = await sdk.trigger({ function_id: "mem::slot-create", payload }); + const resp = result as { success?: boolean; error?: string }; + if (resp?.success === false) { + return { status_code: resp.error?.includes("exists") ? 409 : 400, body: resp }; + } + return { status_code: 201, body: result }; + }); + sdk.registerTrigger({ + type: "http", + function_id: "api::slot-create", + config: { api_path: "/agentmemory/slot", http_method: "POST" }, + }); + + sdk.registerFunction("api::slot-append", async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!isSlotsEnabled()) return slotsDisabledResponse(); + const body = (req.body ?? {}) as Record; + const label = asNonEmptyString(body["label"]); + const text = typeof body["text"] === "string" ? body["text"] : null; + if (!label || !text) return { status_code: 400, body: { error: "label and text required" } }; + const result = await sdk.trigger({ function_id: "mem::slot-append", payload: { label, text } }); + const resp = result as { success?: boolean; error?: string }; + if (resp?.success === false) { + const notFound = resp.error?.includes("not found"); + const overLimit = resp.error?.includes("exceed"); + return { status_code: notFound ? 404 : overLimit ? 413 : 400, body: resp }; + } + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ + type: "http", + function_id: "api::slot-append", + config: { api_path: "/agentmemory/slot/append", http_method: "POST" }, + }); + + sdk.registerFunction("api::slot-replace", async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!isSlotsEnabled()) return slotsDisabledResponse(); + const body = (req.body ?? {}) as Record; + const label = asNonEmptyString(body["label"]); + const content = body["content"]; + if (!label || typeof content !== "string") { + return { status_code: 400, body: { error: "label and content (string) required" } }; + } + const result = await sdk.trigger({ function_id: "mem::slot-replace", payload: { label, content } }); + const resp = result as { success?: boolean; error?: string }; + if (resp?.success === false) { + const notFound = resp.error?.includes("not found"); + const overLimit = resp.error?.includes("exceed"); + return { status_code: notFound ? 404 : overLimit ? 413 : 400, body: resp }; + } + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ + type: "http", + function_id: "api::slot-replace", + config: { api_path: "/agentmemory/slot/replace", http_method: "POST" }, + }); + + sdk.registerFunction("api::slot-delete", async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!isSlotsEnabled()) return slotsDisabledResponse(); + const label = asNonEmptyString(req.query_params?.["label"]); + if (!label) return { status_code: 400, body: { error: "label query param required" } }; + const result = await sdk.trigger({ function_id: "mem::slot-delete", payload: { label } }); + const resp = result as { success?: boolean; error?: string }; + if (resp?.success === false) { + return { status_code: resp.error?.includes("not found") ? 404 : 400, body: resp }; + } + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ + type: "http", + function_id: "api::slot-delete", + config: { api_path: "/agentmemory/slot", http_method: "DELETE" }, + }); + + sdk.registerFunction("api::slot-reflect", async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!isSlotsEnabled()) return slotsDisabledResponse(); + if (!isReflectEnabled()) return reflectDisabledResponse(); + const body = (req.body ?? {}) as Record; + const sessionId = asNonEmptyString(body["sessionId"]); + if (!sessionId) return { status_code: 400, body: { error: "sessionId required" } }; + const maxObservations = parseOptionalPositiveInt(body["maxObservations"]); + if (maxObservations === null) return { status_code: 400, body: { error: "maxObservations must be a positive integer" } }; + const payload: Record = { sessionId }; + if (maxObservations !== undefined) payload["maxObservations"] = maxObservations; + const result = await sdk.trigger({ function_id: "mem::slot-reflect", payload }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ + type: "http", + function_id: "api::slot-reflect", + config: { api_path: "/agentmemory/slot/reflect", http_method: "POST" }, + }); + + sdk.registerFunction("api::action-create", + async ( + req: ApiRequest<{ + title: string; + description?: string; + priority?: number; + createdBy?: string; + project?: string; + tags?: string[]; + parentId?: string; + edges?: Array<{ type: string; targetActionId: string }>; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.title) { + return { status_code: 400, body: { error: "title is required" } }; + } + const result = await sdk.trigger({ function_id: "mem::action-create", payload: req.body }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::action-create", + config: { api_path: "/agentmemory/actions", http_method: "POST" }, + }); + + sdk.registerFunction("api::action-update", + async ( + req: ApiRequest<{ + actionId: string; + status?: string; + title?: string; + description?: string; + priority?: number; + result?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.actionId) { + return { status_code: 400, body: { error: "actionId is required" } }; + } + const result = await sdk.trigger({ function_id: "mem::action-update", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::action-update", + config: { api_path: "/agentmemory/actions/update", http_method: "POST" }, + }); + + sdk.registerFunction("api::action-list", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::action-list", payload: { + status: req.query_params?.["status"], + project: req.query_params?.["project"], + parentId: req.query_params?.["parentId"], + } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::action-list", + config: { api_path: "/agentmemory/actions", http_method: "GET" }, + }); + + sdk.registerFunction("api::action-get", + async (req: ApiRequest<{ actionId: string }>): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const actionId = req.query_params?.["actionId"] as string; + if (!actionId) { + return { status_code: 400, body: { error: "actionId required" } }; + } + const result = await sdk.trigger({ function_id: "mem::action-get", payload: { actionId } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::action-get", + config: { api_path: "/agentmemory/actions/get", http_method: "GET" }, + }); + + sdk.registerFunction("api::action-edge", + async ( + req: ApiRequest<{ + sourceActionId: string; + targetActionId: string; + type: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.sourceActionId || !req.body?.targetActionId || !req.body?.type) { + return { status_code: 400, body: { error: "sourceActionId, targetActionId, and type are required" } }; + } + const result = await sdk.trigger({ function_id: "mem::action-edge-create", payload: req.body }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::action-edge", + config: { api_path: "/agentmemory/actions/edges", http_method: "POST" }, + }); + + sdk.registerFunction("api::frontier", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const parsedLimit = parseOptionalInt(req.query_params?.["limit"]); + const result = await sdk.trigger({ function_id: "mem::frontier", payload: { + project: req.query_params?.["project"], + agentId: req.query_params?.["agentId"], + limit: parsedLimit, + } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::frontier", + config: { api_path: "/agentmemory/frontier", http_method: "GET" }, + }); + + sdk.registerFunction("api::next", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::next", payload: { + project: req.query_params?.["project"], + agentId: req.query_params?.["agentId"], + } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::next", + config: { api_path: "/agentmemory/next", http_method: "GET" }, + }); + + sdk.registerFunction("api::lease-acquire", + async ( + req: ApiRequest<{ actionId: string; agentId: string; ttlMs?: number }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.actionId || !req.body?.agentId) { + return { status_code: 400, body: { error: "actionId and agentId are required" } }; + } + const result = await sdk.trigger({ function_id: "mem::lease-acquire", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::lease-acquire", + config: { api_path: "/agentmemory/leases/acquire", http_method: "POST" }, + }); + + sdk.registerFunction("api::lease-release", + async ( + req: ApiRequest<{ actionId: string; agentId: string; result?: string }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.actionId || !req.body?.agentId) { + return { status_code: 400, body: { error: "actionId and agentId are required" } }; + } + const result = await sdk.trigger({ function_id: "mem::lease-release", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::lease-release", + config: { api_path: "/agentmemory/leases/release", http_method: "POST" }, + }); + + sdk.registerFunction("api::lease-renew", + async ( + req: ApiRequest<{ actionId: string; agentId: string; ttlMs?: number }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.actionId || !req.body?.agentId) { + return { status_code: 400, body: { error: "actionId and agentId are required" } }; + } + const result = await sdk.trigger({ function_id: "mem::lease-renew", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::lease-renew", + config: { api_path: "/agentmemory/leases/renew", http_method: "POST" }, + }); + + sdk.registerFunction("api::routine-create", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.name || !req.body?.steps) { + return { + status_code: 400, + body: { error: "name and steps are required" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::routine-create", payload: req.body }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::routine-create", + config: { api_path: "/agentmemory/routines", http_method: "POST" }, + }); + + sdk.registerFunction("api::routine-list", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::routine-list", payload: { + frozen: req.query_params?.["frozen"] === "true" ? true : undefined, + } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::routine-list", + config: { api_path: "/agentmemory/routines", http_method: "GET" }, + }); + + sdk.registerFunction("api::routine-run", + async ( + req: ApiRequest<{ routineId: string; project?: string; initiatedBy?: string }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.routineId) { + return { status_code: 400, body: { error: "routineId is required" } }; + } + const result = await sdk.trigger({ function_id: "mem::routine-run", payload: req.body }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::routine-run", + config: { api_path: "/agentmemory/routines/run", http_method: "POST" }, + }); + + sdk.registerFunction("api::routine-status", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const runId = req.query_params?.["runId"] as string; + if (!runId) { + return { status_code: 400, body: { error: "runId query param required" } }; + } + const result = await sdk.trigger({ function_id: "mem::routine-status", payload: { runId } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::routine-status", + config: { api_path: "/agentmemory/routines/status", http_method: "GET" }, + }); + + sdk.registerFunction("api::signal-send", + async ( + req: ApiRequest<{ + from: string; + to?: string; + content: string; + type?: string; + replyTo?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.from || !req.body?.content) { + return { status_code: 400, body: { error: "from and content are required" } }; + } + const result = await sdk.trigger({ function_id: "mem::signal-send", payload: req.body }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::signal-send", + config: { api_path: "/agentmemory/signals/send", http_method: "POST" }, + }); + + sdk.registerFunction("api::signal-read", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const agentId = req.query_params?.["agentId"] as string; + if (!agentId) { + return { status_code: 400, body: { error: "agentId query param required" } }; + } + const parsedLimit = parseOptionalInt(req.query_params?.["limit"]); + const result = await sdk.trigger({ function_id: "mem::signal-read", payload: { + agentId, + unreadOnly: req.query_params?.["unreadOnly"] === "true", + threadId: req.query_params?.["threadId"], + limit: parsedLimit, + } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::signal-read", + config: { api_path: "/agentmemory/signals", http_method: "GET" }, + }); + + sdk.registerFunction("api::checkpoint-create", + async ( + req: ApiRequest<{ + name: string; + description?: string; + type?: string; + linkedActionIds?: string[]; + expiresInMs?: number; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.name) { + return { status_code: 400, body: { error: "name is required" } }; + } + const result = await sdk.trigger({ function_id: "mem::checkpoint-create", payload: req.body }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::checkpoint-create", + config: { api_path: "/agentmemory/checkpoints", http_method: "POST" }, + }); + + sdk.registerFunction("api::checkpoint-resolve", + async ( + req: ApiRequest<{ + checkpointId: string; + status: string; + resolvedBy?: string; + result?: unknown; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.checkpointId || !req.body?.status) { + return { status_code: 400, body: { error: "checkpointId and status are required" } }; + } + const result = await sdk.trigger({ function_id: "mem::checkpoint-resolve", payload: req.body }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::checkpoint-resolve", + config: { api_path: "/agentmemory/checkpoints/resolve", http_method: "POST" }, + }); + + sdk.registerFunction("api::checkpoint-list", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::checkpoint-list", payload: { + status: req.query_params?.["status"], + type: req.query_params?.["type"], + } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::checkpoint-list", + config: { api_path: "/agentmemory/checkpoints", http_method: "GET" }, + }); + + sdk.registerFunction("api::mesh-register", + async ( + req: ApiRequest<{ url: string; name: string; sharedScopes?: string[] }>, + ): Promise => { + const secretErr = requireConfiguredSecret(secret, "mesh"); + if (secretErr) return secretErr; + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + if (!req.body?.url || !req.body?.name) { + return { status_code: 400, body: { error: "url and name are required" } }; + } + const result = await sdk.trigger({ function_id: "mem::mesh-register", payload: req.body }); + return { status_code: 201, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::mesh-register", + config: { api_path: "/agentmemory/mesh/peers", http_method: "POST" }, + }); + + sdk.registerFunction("api::mesh-list", + async (req: ApiRequest): Promise => { + const secretErr = requireConfiguredSecret(secret, "mesh"); + if (secretErr) return secretErr; + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::mesh-list", payload: {} }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::mesh-list", + config: { api_path: "/agentmemory/mesh/peers", http_method: "GET" }, + }); + + sdk.registerFunction("api::mesh-sync", + async ( + req: ApiRequest<{ peerId?: string; direction?: string }>, + ): Promise => { + const secretErr = requireConfiguredSecret(secret, "mesh"); + if (secretErr) return secretErr; + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::mesh-sync", payload: req.body || {} }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::mesh-sync", + config: { api_path: "/agentmemory/mesh/sync", http_method: "POST" }, + }); + + sdk.registerFunction("api::mesh-receive", + async (req: ApiRequest): Promise => { + const secretErr = requireConfiguredSecret(secret, "mesh"); + if (secretErr) return secretErr; + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const result = await sdk.trigger({ function_id: "mem::mesh-receive", payload: req.body || {} }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::mesh-receive", + config: { api_path: "/agentmemory/mesh/receive", http_method: "POST" }, + }); + + sdk.registerFunction("api::mesh-export", + async (req: ApiRequest): Promise => { + const secretErr = requireConfiguredSecret(secret, "mesh"); + if (secretErr) return secretErr; + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const since = req.query_params?.["since"] as string; + if (since) { + const parsed = new Date(since).getTime(); + if (Number.isNaN(parsed)) { + return { status_code: 400, body: { error: "Invalid 'since' date format" } }; + } + } + const project = req.query_params?.["project"] as string | undefined; + const sinceTime = since ? new Date(since).getTime() : 0; + const df = (items: T[], field: "updatedAt" | "createdAt") => + items.filter((i) => new Date((i as Record)[field] as string).getTime() > sinceTime); + const memories = await kv.list(KV.memories); + let actions = await kv.list(KV.actions); + if (project) { + actions = actions.filter((a) => a.project === project); + } + const body: Record = { + memories: df(memories, "updatedAt"), + actions: df(actions, "updatedAt"), + }; + if (!project) { + const semantic = await kv.list(KV.semantic); + const procedural = await kv.list(KV.procedural); + const relations = await kv.list(KV.relations); + const graphNodes = await kv.list(KV.graphNodes); + const graphEdges = await kv.list(KV.graphEdges); + body.semantic = df(semantic, "updatedAt"); + body.procedural = df(procedural, "updatedAt"); + body.relations = df(relations, "createdAt"); + body.graphNodes = graphNodes.filter( + (n) => new Date(n.updatedAt || n.createdAt).getTime() > sinceTime, + ); + body.graphEdges = df(graphEdges, "createdAt"); + } + return { status_code: 200, body }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::mesh-export", + config: { api_path: "/agentmemory/mesh/export", http_method: "GET" }, + }); + + sdk.registerFunction("api::flow-compress", + async ( + req: ApiRequest<{ + runId?: string; + actionIds?: string[]; + project?: string; + }>, + ): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + try { + const result = await sdk.trigger({ function_id: "mem::flow-compress", payload: req.body || {} }); + return { status_code: 200, body: result }; + } catch { + return { + status_code: 404, + body: { error: "Flow compression requires a provider" }, + }; + } + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::flow-compress", + config: { api_path: "/agentmemory/flow/compress", http_method: "POST" }, + }); + + sdk.registerFunction("api::branch-detect", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const cwd = (req.query_params?.["cwd"] as string) || process.cwd(); + const result = await sdk.trigger({ function_id: "mem::detect-worktree", payload: { cwd } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::branch-detect", + config: { api_path: "/agentmemory/branch/detect", http_method: "GET" }, + }); + + sdk.registerFunction("api::branch-worktrees", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const cwd = (req.query_params?.["cwd"] as string) || process.cwd(); + const result = await sdk.trigger({ function_id: "mem::list-worktrees", payload: { cwd } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::branch-worktrees", + config: { api_path: "/agentmemory/branch/worktrees", http_method: "GET" }, + }); + + sdk.registerFunction("api::branch-sessions", + async (req: ApiRequest): Promise => { + const authErr = checkAuth(req, secret); + if (authErr) return authErr; + const cwd = (req.query_params?.["cwd"] as string) || process.cwd(); + const result = await sdk.trigger({ function_id: "mem::branch-sessions", payload: { cwd } }); + return { status_code: 200, body: result }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::branch-sessions", + config: { api_path: "/agentmemory/branch/sessions", http_method: "GET" }, + }); + + sdk.registerFunction("api::viewer", + async (req: ApiRequest): Promise => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const rendered = renderViewerDocument(); + if (rendered.found) { + return { + status_code: 200, + headers: { + "Content-Type": "text/html", + "Content-Security-Policy": rendered.csp, + }, + body: rendered.html, + }; + } + return { + status_code: 404, + headers: { + "Content-Type": "text/html", + }, + body: "

agentmemory

viewer not found

", + }; + }, + ); + sdk.registerTrigger({ + type: "http", + function_id: "api::viewer", + config: { api_path: "/agentmemory/viewer", http_method: "GET" }, + }); + + sdk.registerFunction("api::sentinel-create", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.name) return { status_code: 400, body: { error: "name is required" } }; + const result = await sdk.trigger({ function_id: "mem::sentinel-create", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sentinel-create", config: { api_path: "/agentmemory/sentinels", http_method: "POST" } }); + + sdk.registerFunction("api::sentinel-trigger", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.sentinelId) return { status_code: 400, body: { error: "sentinelId is required" } }; + const result = await sdk.trigger({ function_id: "mem::sentinel-trigger", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sentinel-trigger", config: { api_path: "/agentmemory/sentinels/trigger", http_method: "POST" } }); + + sdk.registerFunction("api::sentinel-check", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const result = await sdk.trigger({ function_id: "mem::sentinel-check", payload: {} }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sentinel-check", config: { api_path: "/agentmemory/sentinels/check", http_method: "POST" } }); + + sdk.registerFunction("api::sentinel-cancel", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.sentinelId) return { status_code: 400, body: { error: "sentinelId is required" } }; + const result = await sdk.trigger({ function_id: "mem::sentinel-cancel", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sentinel-cancel", config: { api_path: "/agentmemory/sentinels/cancel", http_method: "POST" } }); + + sdk.registerFunction("api::sentinel-list", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const params = req.query_params || {}; + const result = await sdk.trigger({ function_id: "mem::sentinel-list", payload: { status: params.status, type: params.type } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sentinel-list", config: { api_path: "/agentmemory/sentinels", http_method: "GET" } }); + + sdk.registerFunction("api::sketch-create", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.title) return { status_code: 400, body: { error: "title is required" } }; + const result = await sdk.trigger({ function_id: "mem::sketch-create", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sketch-create", config: { api_path: "/agentmemory/sketches", http_method: "POST" } }); + + sdk.registerFunction("api::sketch-add", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.sketchId || !body?.title) return { status_code: 400, body: { error: "sketchId and title are required" } }; + const result = await sdk.trigger({ function_id: "mem::sketch-add", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sketch-add", config: { api_path: "/agentmemory/sketches/add", http_method: "POST" } }); + + sdk.registerFunction("api::sketch-promote", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.sketchId) return { status_code: 400, body: { error: "sketchId is required" } }; + const result = await sdk.trigger({ function_id: "mem::sketch-promote", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sketch-promote", config: { api_path: "/agentmemory/sketches/promote", http_method: "POST" } }); + + sdk.registerFunction("api::sketch-discard", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.sketchId) return { status_code: 400, body: { error: "sketchId is required" } }; + const result = await sdk.trigger({ function_id: "mem::sketch-discard", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sketch-discard", config: { api_path: "/agentmemory/sketches/discard", http_method: "POST" } }); + + sdk.registerFunction("api::sketch-list", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const params = req.query_params || {}; + const result = await sdk.trigger({ function_id: "mem::sketch-list", payload: { status: params.status, project: params.project } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sketch-list", config: { api_path: "/agentmemory/sketches", http_method: "GET" } }); + + sdk.registerFunction("api::sketch-gc", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const result = await sdk.trigger({ function_id: "mem::sketch-gc", payload: {} }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::sketch-gc", config: { api_path: "/agentmemory/sketches/gc", http_method: "POST" } }); + + sdk.registerFunction("api::crystallize", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.actionIds) return { status_code: 400, body: { error: "actionIds is required" } }; + const result = await sdk.trigger({ function_id: "mem::crystallize", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::crystallize", config: { api_path: "/agentmemory/crystals/create", http_method: "POST" } }); + + sdk.registerFunction("api::crystal-list", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const params = req.query_params || {}; + const limit = parseOptionalPositiveInt(params.limit); + if (limit === null) { + return { + status_code: 400, + body: { error: "invalid numeric parameter: limit" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::crystal-list", payload: { project: params.project, sessionId: params.sessionId, limit } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::crystal-list", config: { api_path: "/agentmemory/crystals", http_method: "GET" } }); + + sdk.registerFunction("api::auto-crystallize", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + const result = await sdk.trigger({ function_id: "mem::auto-crystallize", payload: body || {} }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::auto-crystallize", config: { api_path: "/agentmemory/crystals/auto", http_method: "POST" } }); + + sdk.registerFunction("api::diagnose", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + const result = await sdk.trigger({ function_id: "mem::diagnose", payload: body || {} }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::diagnose", config: { api_path: "/agentmemory/diagnostics", http_method: "POST" } }); + + sdk.registerFunction("api::heal", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + const result = await sdk.trigger({ function_id: "mem::heal", payload: body || {} }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::heal", config: { api_path: "/agentmemory/diagnostics/heal", http_method: "POST" } }); + + sdk.registerFunction("api::facet-tag", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.targetId || !body?.dimension || !body?.value) return { status_code: 400, body: { error: "targetId, dimension, and value are required" } }; + const result = await sdk.trigger({ function_id: "mem::facet-tag", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::facet-tag", config: { api_path: "/agentmemory/facets", http_method: "POST" } }); + + sdk.registerFunction("api::facet-untag", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.targetId || !body?.dimension) return { status_code: 400, body: { error: "targetId and dimension are required" } }; + const result = await sdk.trigger({ function_id: "mem::facet-untag", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::facet-untag", config: { api_path: "/agentmemory/facets/remove", http_method: "POST" } }); + + sdk.registerFunction("api::facet-query", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + const result = await sdk.trigger({ function_id: "mem::facet-query", payload: body || {} }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::facet-query", config: { api_path: "/agentmemory/facets/query", http_method: "POST" } }); + + sdk.registerFunction("api::facet-get", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const params = req.query_params || {}; + if (!params.targetId) return { status_code: 400, body: { error: "targetId query param is required" } }; + const result = await sdk.trigger({ function_id: "mem::facet-get", payload: { targetId: params.targetId } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::facet-get", config: { api_path: "/agentmemory/facets", http_method: "GET" } }); + + sdk.registerFunction("api::facet-stats", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const params = req.query_params || {}; + const result = await sdk.trigger({ function_id: "mem::facet-stats", payload: { targetType: params.targetType } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::facet-stats", config: { api_path: "/agentmemory/facets/stats", http_method: "GET" } }); + + sdk.registerFunction("api::verify", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.id || typeof body.id !== "string") return { status_code: 400, body: { error: "id is required" } }; + const result = await sdk.trigger({ function_id: "mem::verify", payload: { id: body.id } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::verify", config: { api_path: "/agentmemory/verify", http_method: "POST" } }); + + sdk.registerFunction("api::cascade-update", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.supersededMemoryId || typeof body.supersededMemoryId !== "string") { + return { status_code: 400, body: { error: "supersededMemoryId is required" } }; + } + const result = await sdk.trigger({ function_id: "mem::cascade-update", payload: { supersededMemoryId: body.supersededMemoryId } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::cascade-update", config: { api_path: "/agentmemory/cascade-update", http_method: "POST" } }); + + sdk.registerFunction("api::lesson-save", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.content || typeof body.content !== "string") return { status_code: 400, body: { error: "content is required" } }; + const tags = typeof body.tags === "string" ? (body.tags as string).split(",").map((t: string) => t.trim()).filter(Boolean) : Array.isArray(body.tags) ? body.tags : []; + const result = (await sdk.trigger({ + function_id: "mem::lesson-save", + payload: { + content: body.content, + context: body.context || "", + confidence: typeof body.confidence === "number" ? body.confidence : undefined, + project: typeof body.project === "string" ? body.project : undefined, + tags, + source: "manual", + }, + })) as { action?: string }; + const statusCode = result?.action === "created" ? 201 : 200; + return { status_code: statusCode, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::lesson-save", config: { api_path: "/agentmemory/lessons", http_method: "POST" } }); + + sdk.registerFunction("api::lesson-list", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const params = req.query_params || {}; + const minConfidence = parseOptionalFiniteNumber(params.minConfidence); + if (minConfidence === null) { + return { + status_code: 400, + body: { error: "invalid numeric parameter: minConfidence" }, + }; + } + const limit = parseOptionalPositiveInt(params.limit); + if (limit === null) { + return { + status_code: 400, + body: { error: "invalid numeric parameter: limit" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::lesson-list", payload: { + project: params.project, + source: params.source, + minConfidence, + limit, + } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::lesson-list", config: { api_path: "/agentmemory/lessons", http_method: "GET" } }); + + sdk.registerFunction("api::lesson-search", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.query || typeof body.query !== "string") return { status_code: 400, body: { error: "query is required" } }; + const result = await sdk.trigger({ function_id: "mem::lesson-recall", payload: body }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::lesson-search", config: { api_path: "/agentmemory/lessons/search", http_method: "POST" } }); + + sdk.registerFunction("api::lesson-strengthen", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.lessonId || typeof body.lessonId !== "string") return { status_code: 400, body: { error: "lessonId is required" } }; + const result = await sdk.trigger({ function_id: "mem::lesson-strengthen", payload: { lessonId: body.lessonId } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::lesson-strengthen", config: { api_path: "/agentmemory/lessons/strengthen", http_method: "POST" } }); + + sdk.registerFunction("api::obsidian-export", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = (req.body as Record) || {}; + const vaultDir = asNonEmptyString(body.vaultDir); + if (!vaultDir) { + return { + status_code: 400, + body: { error: "vaultDir must be a non-empty string" }, + }; + } + const types = typeof body.types === "string" ? body.types.split(",").map((t: string) => t.trim()).filter(Boolean) : undefined; + const result = await sdk.trigger({ function_id: "mem::obsidian-export", payload: { vaultDir, types } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::obsidian-export", config: { api_path: "/agentmemory/obsidian/export", http_method: "POST" } }); + + sdk.registerFunction("api::reflect", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = (req.body as Record) || {}; + const result = await sdk.trigger({ function_id: "mem::reflect", payload: { + project: typeof body.project === "string" ? body.project : undefined, + maxClusters: typeof body.maxClusters === "number" ? body.maxClusters : undefined, + } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::reflect", config: { api_path: "/agentmemory/reflect", http_method: "POST" } }); + + sdk.registerFunction("api::insight-list", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const params = req.query_params || {}; + const minConfidence = parseOptionalFiniteNumber(params.minConfidence); + if (minConfidence === null) { + return { + status_code: 400, + body: { error: "invalid numeric parameter: minConfidence" }, + }; + } + const limit = parseOptionalPositiveInt(params.limit); + if (limit === null) { + return { + status_code: 400, + body: { error: "invalid numeric parameter: limit" }, + }; + } + const result = await sdk.trigger({ function_id: "mem::insight-list", payload: { + project: params.project, + minConfidence, + limit, + } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::insight-list", config: { api_path: "/agentmemory/insights", http_method: "GET" } }); + + sdk.registerFunction("api::insight-search", async (req: ApiRequest) => { + const denied = checkAuth(req, secret); + if (denied) return denied; + const body = req.body as Record; + if (!body?.query || typeof body.query !== "string") return { status_code: 400, body: { error: "query is required" } }; + const result = await sdk.trigger({ function_id: "mem::insight-search", payload: { + query: body.query, + project: typeof body.project === "string" ? body.project : undefined, + minConfidence: typeof body.minConfidence === "number" ? body.minConfidence : undefined, + limit: typeof body.limit === "number" ? body.limit : undefined, + } }); + return { status_code: 200, body: result }; + }); + sdk.registerTrigger({ type: "http", function_id: "api::insight-search", config: { api_path: "/agentmemory/insights/search", http_method: "POST" } }); +} diff --git a/src/triggers/events.ts b/src/triggers/events.ts new file mode 100644 index 0000000..e38b58d --- /dev/null +++ b/src/triggers/events.ts @@ -0,0 +1,146 @@ +import { TriggerAction, type ISdk } from "iii-sdk"; +import type { CompressedObservation, HookPayload, Session } from "../types.js"; +import { KV, STREAM } from "../state/schema.js"; +import { StateKV } from "../state/kv.js"; +import { isReflectEnabled } from "../functions/slots.js"; +import { isGraphExtractionEnabled } from "../config.js"; +import { logger } from "../logger.js"; + +export function registerEventTriggers(sdk: ISdk, kv: StateKV): void { + sdk.registerFunction( + "event::session::started", + async (data: { sessionId: string; project: string; cwd: string }) => { + const session: Session = { + id: data.sessionId, + project: data.project, + cwd: data.cwd, + startedAt: new Date().toISOString(), + status: "active", + observationCount: 0, + }; + await kv.set(KV.sessions, data.sessionId, session); + const contextResult = await sdk.trigger< + { sessionId: string; project: string }, + { context: string } + >({ + function_id: "mem::context", + payload: { sessionId: data.sessionId, project: data.project }, + }); + return { session, context: contextResult.context }; + }, + ); + sdk.registerTrigger({ + type: "durable:subscriber", + function_id: "event::session::started", + config: { topic: "agentmemory.session.started" }, + }); + + sdk.registerFunction("event::observation", async (data: HookPayload) => + sdk.trigger({ function_id: "mem::observe", payload: data }), + ); + sdk.registerTrigger({ + type: "durable:subscriber", + function_id: "event::observation", + config: { topic: "agentmemory.observation" }, + }); + + sdk.registerFunction("event::session::stopped", async (data: { sessionId: string }) => { + const summary = await sdk.trigger({ function_id: "mem::summarize", payload: data }); + if (isReflectEnabled()) { + try { + sdk.trigger({ + function_id: "mem::slot-reflect", + payload: { sessionId: data.sessionId }, + action: TriggerAction.Void(), + }); + } catch (err) { + logger.warn("slot-reflect trigger failed", { + sessionId: data.sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + 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), + }); + } + } + return summary; + }); + sdk.registerTrigger({ + type: "durable:subscriber", + function_id: "event::session::stopped", + config: { topic: "agentmemory.session.stopped" }, + }); + + sdk.registerFunction( + "event::session::ended", + async (data: { sessionId: string }) => { + await kv.update(KV.sessions, data.sessionId, [ + { type: "set", path: "endedAt", value: new Date().toISOString() }, + { type: "set", path: "status", value: "completed" }, + ]); + return { success: true }; + }, + ); + sdk.registerTrigger({ + type: "durable:subscriber", + function_id: "event::session::ended", + config: { topic: "agentmemory.session.ended" }, + }); + + // React to observation count changes and emit a lightweight live event for dashboards/viewer. + sdk.registerFunction( + "event::session::observation-count-changed", + async (payload: { + key: string; + event_type: string; + old_value?: Session; + new_value?: Session; + }) => { + if (payload.event_type === "delete") return { skipped: true }; + const oldCount = payload.old_value?.observationCount ?? 0; + const newCount = payload.new_value?.observationCount ?? 0; + if (newCount <= oldCount) return { skipped: true }; + + await sdk.trigger({ + function_id: "stream::send", + payload: { + stream_name: STREAM.name, + group_id: STREAM.viewerGroup, + id: `session-activity-${payload.key}-${Date.now()}`, + type: "session.activity", + data: { + sessionId: payload.key, + observationCount: newCount, + delta: newCount - oldCount, + updatedAt: payload.new_value?.updatedAt ?? new Date().toISOString(), + }, + }, + action: TriggerAction.Void(), + }); + + return { emitted: true }; + }, + ); + sdk.registerTrigger({ + type: "state", + function_id: "event::session::observation-count-changed", + config: { scope: KV.sessions }, + }); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6797dfa --- /dev/null +++ b/src/types.ts @@ -0,0 +1,950 @@ +export interface Session { + id: string; + project: string; + cwd: string; + startedAt: string; + endedAt?: string; + status: "active" | "completed" | "abandoned"; + observationCount: number; + model?: string; + tags?: string[]; + firstPrompt?: string; + summary?: string; + commitShas?: string[]; + agentId?: string; +} + +export interface CommitLink { + sha: string; + shortSha: string; + branch?: string; + repo?: string; + message?: string; + author?: string; + authoredAt?: string; + files?: string[]; + sessionIds: string[]; + linkedAt: string; +} + +export interface RawObservation { + id: string; + sessionId: string; + timestamp: string; + hookType: HookType; + toolName?: string; + toolInput?: unknown; + toolOutput?: unknown; + userPrompt?: string; + assistantResponse?: string; + raw: unknown; + modality?: "text" | "image" | "mixed"; + imageData?: string; + agentId?: string; +} + +export interface CompressedObservation { + id: string; + sessionId: string; + timestamp: string; + type: ObservationType; + title: string; + subtitle?: string; + facts: string[]; + narrative: string; + concepts: string[]; + files: string[]; + importance: number; + confidence?: number; + imageRef?: string; + imageData?: string; + imageDescription?: string; + modality?: "text" | "image" | "mixed"; + agentId?: string; +} + +export type ObservationType = + | "file_read" + | "file_write" + | "file_edit" + | "command_run" + | "search" + | "web_fetch" + | "conversation" + | "error" + | "decision" + | "discovery" + | "subagent" + | "notification" + | "task" + | "image" + | "other"; + +export interface Memory { + id: string; + createdAt: string; + updatedAt: string; + type: "pattern" | "preference" | "architecture" | "bug" | "workflow" | "fact"; + title: string; + content: string; + concepts: string[]; + files: string[]; + sessionIds: string[]; + strength: number; + version: number; + parentId?: string; + supersedes?: string[]; + relatedIds?: string[]; + sourceObservationIds?: string[]; + isLatest: boolean; + forgetAfter?: string; + imageRef?: string; + imageData?: string; + agentId?: string; + project?: string; +} + +export interface SessionSummary { + sessionId: string; + project: string; + createdAt: string; + title: string; + narrative: string; + keyDecisions: string[]; + filesModified: string[]; + concepts: string[]; + observationCount: number; +} + +export type HookType = + | "session_start" + | "prompt_submit" + | "pre_tool_use" + | "post_tool_use" + | "post_tool_failure" + | "pre_compact" + | "subagent_start" + | "subagent_stop" + | "notification" + | "task_completed" + | "stop" + | "session_end"; + +export interface HookPayload { + hookType: HookType; + sessionId: string; + project: string; + cwd: string; + timestamp: string; + data: unknown; +} + +export interface ProviderConfig { + provider: ProviderType; + model: string; + maxTokens: number; + /** Optional base URL override (e.g. for Anthropic-compatible APIs or local proxies) */ + baseURL?: string; +} + +export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter" | "minimax" | "openai" | "noop"; + +export interface MemoryProvider { + name: string; + compress(systemPrompt: string, userPrompt: string): Promise; + summarize(systemPrompt: string, userPrompt: string): Promise; + describeImage?(imageData: string, mimeType: string, prompt: string): Promise; +} + +export interface AgentMemoryConfig { + engineUrl: string; + restPort: number; + streamsPort: number; + provider: ProviderConfig; + tokenBudget: number; + maxObservationsPerSession: number; + compressionModel: string; + dataDir: string; +} + +export interface SearchResult { + observation: CompressedObservation; + score: number; + sessionId: string; +} + +export interface ContextBlock { + type: "summary" | "observation" | "memory"; + content: string; + tokens: number; + recency: number; + sourceIds?: string[]; +} + +export interface EvalResult { + valid: boolean; + errors: string[]; + qualityScore: number; + latencyMs: number; + functionId: string; +} + +export interface FunctionMetrics { + functionId: string; + totalCalls: number; + successCount: number; + failureCount: number; + avgLatencyMs: number; + avgQualityScore: number; +} + +export interface HealthSnapshot { + connectionState: string; + workers: Array<{ id: string; name: string; status: string }>; + memory: { + heapUsed: number; + heapTotal: number; + rss: number; + external: number; + }; + cpu: { userMicros: number; systemMicros: number; percent: number }; + eventLoopLagMs: number; + uptimeSeconds: number; + kvConnectivity?: { status: string; latencyMs?: number; error?: string }; + status: "healthy" | "degraded" | "critical"; + alerts: string[]; + notes?: string[]; +} + +export interface CircuitBreakerState { + state: "closed" | "open" | "half-open"; + failures: number; + lastFailureAt: number | null; + openedAt: number | null; +} + +export interface MemorySlot { + label: string; + content: string; + sizeLimit: number; + description: string; + pinned: boolean; + readOnly: boolean; + scope: "project" | "global"; + createdAt: string; + updatedAt: string; +} + +export interface EmbeddingProvider { + name: string; + dimensions: number; + embed(text: string): Promise; + embedBatch(texts: string[]): Promise; + embedImage?(src: string): Promise; +} + +export interface MemoryRelation { + type: "supersedes" | "extends" | "derives" | "contradicts" | "related"; + sourceId: string; + targetId: string; + createdAt: string; + confidence?: number; +} + +export interface HybridSearchResult { + observation: CompressedObservation; + bm25Score: number; + vectorScore: number; + graphScore: number; + combinedScore: number; + sessionId: string; + graphContext?: string; +} + +export interface CompactSearchResult { + obsId: string; + sessionId: string; + title: string; + type: ObservationType; + score: number; + timestamp: string; +} + +export interface CompactLessonResult { + lessonId: string; + content: string; + confidence: number; + score: number; + createdAt: string; + project?: string; + tags: string[]; +} + +export interface TimelineEntry { + observation: CompressedObservation; + sessionId: string; + relativePosition: number; +} + +export interface ProjectProfile { + project: string; + updatedAt: string; + topConcepts: Array<{ concept: string; frequency: number }>; + topFiles: Array<{ file: string; frequency: number }>; + conventions: string[]; + commonErrors: string[]; + recentActivity: string[]; + sessionCount: number; + totalObservations: number; + summary?: string; +} + +export interface ExportPagination { + offset: number; + limit: number; + total: number; + hasMore: boolean; +} + +export interface ExportData { + version: "0.3.0" | "0.4.0" | "0.5.0" | "0.6.0" | "0.6.1" | "0.7.0" | "0.7.2" | "0.7.3" | "0.7.4" | "0.7.5" | "0.7.6" | "0.7.7" | "0.7.9" | "0.8.0" | "0.8.1" | "0.8.2" | "0.8.3" | "0.8.4" | "0.8.5" | "0.8.6" | "0.8.7" | "0.8.8" | "0.8.9" | "0.8.10" | "0.8.11" | "0.8.12" | "0.8.13" | "0.9.0" | "0.9.1" | "0.9.2" | "0.9.3" | "0.9.4" | "0.9.5" | "0.9.6" | "0.9.7" | "0.9.8" | "0.9.9" | "0.9.10" | "0.9.11" | "0.9.12" | "0.9.13" | "0.9.14" | "0.9.15" | "0.9.16" | "0.9.17" | "0.9.18" | "0.9.19" | "0.9.20" | "0.9.21" | "0.9.22" | "0.9.23" | "0.9.24" | "0.9.25" | "0.9.26" | "0.9.27"; + exportedAt: string; + sessions: Session[]; + observations: Record; + memories: Memory[]; + summaries: SessionSummary[]; + profiles?: ProjectProfile[]; + graphNodes?: GraphNode[]; + graphEdges?: GraphEdge[]; + semanticMemories?: SemanticMemory[]; + proceduralMemories?: ProceduralMemory[]; + actions?: Action[]; + actionEdges?: ActionEdge[]; + routines?: Routine[]; + signals?: Signal[]; + checkpoints?: Checkpoint[]; + sentinels?: Sentinel[]; + sketches?: Sketch[]; + crystals?: Crystal[]; + facets?: Facet[]; + lessons?: Lesson[]; + insights?: Insight[]; + accessLogs?: AccessLogExport[]; + pagination?: ExportPagination; +} + +export interface AccessLogExport { + memoryId: string; + count: number; + lastAt: string; + recent: number[]; +} + +export interface EmbeddingConfig { + provider?: string; + bm25Weight: number; + vectorWeight: number; +} + +export interface FallbackConfig { + providers: ProviderType[]; +} + +export interface ClaudeBridgeConfig { + enabled: boolean; + projectPath: string; + memoryFilePath: string; + lineBudget: number; +} + +export interface StandaloneConfig { + dataDir: string; + persistPath: string; + agentType?: string; +} + +export type GraphNodeType = + | "file" + | "function" + | "concept" + | "error" + | "decision" + | "pattern" + | "library" + | "person" + | "project" + | "preference" + | "location" + | "organization" + | "event"; + +export interface GraphNode { + id: string; + type: GraphNodeType; + name: string; + properties: Record; + sourceObservationIds: string[]; + createdAt: string; + updatedAt?: string; + aliases?: string[]; + stale?: boolean; +} + +export type GraphEdgeType = + | "uses" + | "imports" + | "modifies" + | "causes" + | "fixes" + | "depends_on" + | "related_to" + | "works_at" + | "prefers" + | "blocked_by" + | "caused_by" + | "optimizes_for" + | "rejected" + | "avoids" + | "located_in" + | "succeeded_by"; + +export interface GraphEdge { + id: string; + type: GraphEdgeType; + sourceNodeId: string; + targetNodeId: string; + weight: number; + sourceObservationIds: string[]; + createdAt: string; + tcommit?: string; + tvalid?: string; + tvalidEnd?: string; + context?: EdgeContext; + version?: number; + supersededBy?: string; + isLatest?: boolean; + stale?: boolean; +} + +export interface EdgeContext { + reasoning?: string; + sentiment?: string; + alternatives?: string[]; + situationalFactors?: string[]; + confidence?: number; +} + +export interface GraphQueryResult { + nodes: GraphNode[]; + edges: GraphEdge[]; + depth: number; + // #753: pagination + truncation signals for large graphs. `total*` + // counts reflect the full unbounded result for the given filter so + // the viewer can show "showing N of M" without re-querying. `truncated` + // is true when the default cap kicked in (operator may have wanted + // the full set but didn't ask for one). + totalNodes?: number; + totalEdges?: number; + truncated?: boolean; + // Echoes back the cap that produced this page so a paged client can + // detect when the default was applied vs an explicit `limit`. + limit?: number; + offset?: number; + // #814: indicates the response came from the precomputed top-degree + // snapshot rather than a live kv.list enumeration. Set only on the + // empty-body / nodeType-only branch on large corpora where the + // unbounded enumeration would exceed the iii invocation timeout. + fromSnapshot?: boolean; + // #814: when the snapshot is stale or absent and the live fallback + // also failed, expose an explanatory note so the viewer can surface + // an actionable banner instead of a blank graph. + warning?: string; +} + +// #814: persisted top-degree subgraph + aggregate counts. Stored under +// KV.graphSnapshot with a single key "current". `dirty` is set true by +// mem::graph-extract after writes and flipped false when the snapshot +// rebuild completes. +export interface GraphSnapshot { + version: 1; + topNodes: GraphNode[]; + topEdges: GraphEdge[]; + // Synchronous degree lookup keyed by nodeId. Maintained alongside + // topNodes so re-ranking after an edge write doesn't require an + // async kv.get for every top-N entry inside the sort comparator. + // Keys are limited to the top-N set; non-top nodes track their + // degree in KV.graphNodeDegree only. + topDegrees: Record; + stats: { + totalNodes: number; + totalEdges: number; + nodesByType: Record; + edgesByType: Record; + }; + updatedAt: string; + dirty: boolean; + // #825 follow-up: ISO timestamp set by mem::graph-reset. After + // reset, mem::graph-extract treats any pre-resetAt node as an + // orphan (skip merge, write fresh) so future extracts don't + // silently reconnect to legacy rows via stale name-index entries. + // Absent / 1970 epoch = no reset has run. + resetAt?: string; +} + +export type ConsolidationTier = + | "working" + | "episodic" + | "semantic" + | "procedural"; + +export interface SemanticMemory { + id: string; + fact: string; + confidence: number; + sourceSessionIds: string[]; + sourceMemoryIds: string[]; + accessCount: number; + lastAccessedAt: string; + strength: number; + createdAt: string; + updatedAt: string; +} + +export interface ProceduralMemory { + id: string; + name: string; + steps: string[]; + triggerCondition: string; + expectedOutcome?: string; + frequency: number; + sourceSessionIds: string[]; + sourceObservationIds?: string[]; + tags?: string[]; + concepts?: string[]; + strength: number; + createdAt: string; + updatedAt: string; +} + +export interface TeamConfig { + teamId: string; + userId: string; + mode: "shared" | "private"; +} + +export type AgentScopeMode = "shared" | "isolated"; +export interface AgentScope { + agentId: string; + mode: AgentScopeMode; +} + +export interface TeamSharedItem { + id: string; + sharedBy: string; + sharedAt: string; + type: "observation" | "memory" | "pattern"; + content: unknown; + project: string; + visibility: "shared" | "private"; +} + +export interface TeamProfile { + teamId: string; + members: string[]; + topConcepts: Array<{ concept: string; frequency: number }>; + topFiles: Array<{ file: string; frequency: number }>; + sharedPatterns: string[]; + totalSharedItems: number; + updatedAt: string; +} + +export interface AuditEntry { + id: string; + timestamp: string; + operation: + | "observe" + | "compress" + | "remember" + | "forget" + | "evolve" + | "consolidate" + | "share" + | "delete" + | "import" + | "export" + | "action_create" + | "action_update" + | "lease_acquire" + | "lease_release" + | "routine_run" + | "signal_send" + | "checkpoint_resolve" + | "mesh_sync" + | "relation_create" + | "relation_update" + | "sentinel_create" + | "sentinel_trigger" + | "sketch_create" + | "sketch_promote" + | "retention_score" + | "sketch_discard" + | "crystallize" + | "diagnose" + | "heal" + | "index_persist" + | "facet_tag" + | "lesson_save" + | "lesson_recall" + | "lesson_strengthen" + | "obsidian_export" + | "reflect" + | "insight_search" + | "skill_extract" + | "core_add" + | "core_remove" + | "auto_page" + | "vision_embed" + | "slot_append" + | "slot_replace" + | "slot_create" + | "slot_delete" + | "slot_reflect"; + userId?: string; + functionId: string; + targetIds: string[]; + details: Record; + qualityScore?: number; +} + +export interface GovernanceFilter { + type?: string[]; + dateFrom?: string; + dateTo?: string; + project?: string; + qualityBelow?: number; +} + +export interface SnapshotMeta { + id: string; + commitHash: string; + createdAt: string; + message: string; + stats: { + sessions: number; + observations: number; + memories: number; + graphNodes: number; + }; +} + +export interface SnapshotDiff { + fromCommit: string; + toCommit: string; + added: { memories: number; observations: number; graphNodes: number }; + removed: { memories: number; observations: number; graphNodes: number }; +} + +export interface Action { + id: string; + title: string; + description: string; + status: "pending" | "active" | "done" | "blocked" | "cancelled"; + priority: number; + createdAt: string; + updatedAt: string; + createdBy: string; + assignedTo?: string; + project?: string; + tags: string[]; + sourceObservationIds: string[]; + sourceMemoryIds: string[]; + result?: string; + parentId?: string; + metadata?: Record; + sketchId?: string; + crystallizedInto?: string; +} + +export type ActionEdgeType = + | "requires" + | "unlocks" + | "spawned_by" + | "gated_by" + | "conflicts_with"; + +export interface ActionEdge { + id: string; + type: ActionEdgeType; + sourceActionId: string; + targetActionId: string; + createdAt: string; + metadata?: Record; +} + +export interface Lease { + id: string; + actionId: string; + agentId: string; + acquiredAt: string; + expiresAt: string; + renewedAt?: string; + status: "active" | "expired" | "released"; +} + +export interface Routine { + id: string; + name: string; + description: string; + steps: RoutineStep[]; + createdAt: string; + updatedAt: string; + frozen: boolean; + tags: string[]; + sourceProceduralIds: string[]; +} + +export interface RoutineStep { + order: number; + title: string; + description: string; + actionTemplate: Partial; + dependsOn: number[]; +} + +export interface RoutineRun { + id: string; + routineId: string; + status: "running" | "completed" | "failed" | "paused"; + startedAt: string; + completedAt?: string; + actionIds: string[]; + stepStatus: Record; + initiatedBy: string; +} + +export interface Signal { + id: string; + from: string; + to?: string; + threadId?: string; + replyTo?: string; + type: "info" | "request" | "response" | "alert" | "handoff"; + content: string; + metadata?: Record; + createdAt: string; + readAt?: string; + expiresAt?: string; +} + +export interface Checkpoint { + id: string; + name: string; + description: string; + status: "pending" | "passed" | "failed" | "expired"; + type: "ci" | "approval" | "deploy" | "external" | "timer"; + createdAt: string; + resolvedAt?: string; + resolvedBy?: string; + result?: unknown; + expiresAt?: string; + linkedActionIds: string[]; +} + +export interface Sketch { + id: string; + title: string; + description: string; + status: "active" | "promoted" | "discarded"; + actionIds: string[]; + project?: string; + createdAt: string; + expiresAt: string; + promotedAt?: string; + discardedAt?: string; +} + +export interface Facet { + id: string; + targetId: string; + targetType: "action" | "memory" | "observation"; + dimension: string; + value: string; + createdAt: string; +} + +export interface Sentinel { + id: string; + name: string; + type: "webhook" | "timer" | "threshold" | "pattern" | "approval" | "custom"; + status: "watching" | "triggered" | "cancelled" | "expired"; + config: Record; + result?: unknown; + createdAt: string; + triggeredAt?: string; + expiresAt?: string; + linkedActionIds: string[]; + escalatedAt?: string; +} + +export interface Crystal { + id: string; + narrative: string; + keyOutcomes: string[]; + filesAffected: string[]; + lessons: string[]; + sourceActionIds: string[]; + sessionId?: string; + project?: string; + createdAt: string; +} + +export interface Lesson { + id: string; + content: string; + context: string; + confidence: number; + reinforcements: number; + source: "crystal" | "manual" | "consolidation"; + sourceIds: string[]; + project?: string; + tags: string[]; + createdAt: string; + updatedAt: string; + lastReinforcedAt?: string; + lastDecayedAt?: string; + decayRate: number; + deleted?: boolean; +} + +export interface Insight { + id: string; + title: string; + content: string; + confidence: number; + reinforcements: number; + sourceConceptCluster: string[]; + sourceMemoryIds: string[]; + sourceLessonIds: string[]; + sourceCrystalIds: string[]; + project?: string; + tags: string[]; + createdAt: string; + updatedAt: string; + lastReinforcedAt?: string; + lastDecayedAt?: string; + decayRate: number; + deleted?: boolean; +} + +export interface DiagnosticCheck { + name: string; + category: string; + status: "pass" | "warn" | "fail"; + message: string; + fixable: boolean; +} + +export interface MeshPeer { + id: string; + url: string; + name: string; + lastSyncAt?: string; + status: "connected" | "disconnected" | "syncing" | "error"; + sharedScopes: string[]; + syncFilter?: { project?: string }; +} + + +export interface EnrichedChunk { + id: string; + originalObsId: string; + sessionId: string; + content: string; + resolvedEntities: Record; + preferences: string[]; + contextBridges: string[]; + windowStart: number; + windowEnd: number; + createdAt: string; +} + +export interface LatentEmbedding { + obsId: string; + contentEmbedding: string; + latentEmbedding: string; + sessionId: string; +} + +export interface QueryExpansion { + original: string; + reformulations: string[]; + temporalConcretizations: string[]; + entityExtractions: string[]; +} + +export interface TripleStreamResult { + observation: CompressedObservation; + vectorScore: number; + bm25Score: number; + graphScore: number; + combinedScore: number; + sessionId: string; + graphContext?: string; +} + +export interface TemporalQuery { + entityName: string; + asOf?: string; + from?: string; + to?: string; + includeHistory?: boolean; +} + +export interface TemporalState { + entity: GraphNode; + currentEdges: GraphEdge[]; + historicalEdges: GraphEdge[]; + timeline: Array<{ + edge: GraphEdge; + validFrom: string; + validTo?: string; + context?: EdgeContext; + }>; +} + +export interface RetentionScore { + memoryId: string; + // Which KV scope this row came from. Needed by mem::retention-evict + // so the delete loop routes to KV.memories or KV.semantic correctly. + // Missing on pre-0.8.10 rows — callers must treat `undefined` as + // "unknown" and probe both scopes for backwards-compat. See #124. + source?: "episodic" | "semantic"; + score: number; + salience: number; + temporalDecay: number; + reinforcementBoost: number; + lastAccessed: string; + accessCount: number; +} + +export interface DecayConfig { + lambda: number; + sigma: number; + tierThresholds: { + hot: number; + warm: number; + cold: number; + }; +} + +/** + * KV.state scope — long-lived system counters + flags keyed by string. + * Keep keys/types in sync with the state-scope callers (e.g., + * disk-size-manager) so TypeScript enforces consistent value shapes + * instead of every caller using ad-hoc `` generics. + */ +export interface StateScope { + "system:currentDiskSize": number; +} + +export type StateScopeKey = keyof StateScope; diff --git a/src/utils/image-store.ts b/src/utils/image-store.ts new file mode 100644 index 0000000..3197e2c --- /dev/null +++ b/src/utils/image-store.ts @@ -0,0 +1,90 @@ +import { homedir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile, unlink, utimes, stat } from "node:fs/promises"; +import { createHash } from "node:crypto"; + +export const IMAGES_DIR = join(homedir(), ".agentmemory", "images"); + +const DEFAULT_MAX_BYTES = 500 * 1024 * 1024; + +export function getMaxBytes(): number { + return Number(process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES) || DEFAULT_MAX_BYTES; +} + +export function isManagedImagePath(filePath: string): boolean { + const resolved = resolve(filePath); + const normalizedImagesDir = resolve(IMAGES_DIR); + return resolved.startsWith(normalizedImagesDir + sep) || resolved === normalizedImagesDir; +} + +function contentHash(data: string): string { + return createHash("sha256").update(data).digest("hex"); +} + +export async function saveImageToDisk(base64Data: string): Promise<{ filePath: string; bytesWritten: number }> { + if (!base64Data) return { filePath: "", bytesWritten: 0 }; + + if (!existsSync(IMAGES_DIR)) { + await mkdir(IMAGES_DIR, { recursive: true }); + } + + let cleanBase64 = base64Data; + let ext = "png"; + + if (base64Data.startsWith("data:image/")) { + const commaIdx = base64Data.indexOf(","); + if (commaIdx !== -1) { + const meta = base64Data.substring(0, commaIdx); + if (meta.includes("jpeg") || meta.includes("jpg")) ext = "jpg"; + else if (meta.includes("webp")) ext = "webp"; + else if (meta.includes("gif")) ext = "gif"; + cleanBase64 = base64Data.substring(commaIdx + 1); + } + } else if (base64Data.startsWith("/9j/")) { + ext = "jpg"; + } + + const hash = contentHash(cleanBase64); + const filePath = join(IMAGES_DIR, `${hash}.${ext}`); + + if (existsSync(filePath)) { + return { filePath, bytesWritten: 0 }; + } + + const buffer = Buffer.from(cleanBase64, "base64"); + await writeFile(filePath, buffer); + + const s = await stat(filePath); + + return { filePath, bytesWritten: s.size }; +} + +export async function deleteImage(filePath: string | undefined): Promise<{ deletedBytes: number }> { + if (!filePath) return { deletedBytes: 0 }; + if (!isManagedImagePath(filePath)) return { deletedBytes: 0 }; + try { + if (existsSync(filePath)) { + const s = await stat(filePath); + const size = s.size; + await unlink(filePath); + return { deletedBytes: size }; + } + } catch (err) { + console.error("[agentmemory] Failed to delete image context:", err); + } + return { deletedBytes: 0 }; +} + +/** Touch an image file to update its mtime (marking it as recently used for LRU eviction) */ +export async function touchImage(filePath: string): Promise { + if (!filePath || !isManagedImagePath(filePath)) return; + try { + if (existsSync(filePath)) { + const now = new Date(); + await utimes(filePath, now, now); + } + } catch (err) { + // Ignore touch errors silently + } +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..9278460 --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const VERSION = "0.9.27"; diff --git a/src/viewer/document.ts b/src/viewer/document.ts new file mode 100644 index 0000000..f8da5aa --- /dev/null +++ b/src/viewer/document.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + VIEWER_NONCE_PLACEHOLDER, + createViewerNonce, + buildViewerCsp, +} from "../auth.js"; +import { VERSION } from "../version.js"; + +const VIEWER_VERSION_PLACEHOLDER = "__AGENTMEMORY_VERSION__"; + +function loadViewerTemplate(): string | null { + const base = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(base, "..", "src", "viewer", "index.html"), + join(base, "..", "viewer", "index.html"), + join(base, "viewer", "index.html"), + ]; + for (const path of candidates) { + try { + return readFileSync(path, "utf-8"); + } catch {} + } + return null; +} + +export function renderViewerDocument(): + | { found: true; html: string; csp: string } + | { found: false } { + const template = loadViewerTemplate(); + if (!template) { + return { found: false }; + } + + const nonce = createViewerNonce(); + const html = template + .replaceAll(VIEWER_NONCE_PLACEHOLDER, nonce) + .replaceAll(VIEWER_VERSION_PLACEHOLDER, VERSION); + return { + found: true, + html, + csp: buildViewerCsp(nonce), + }; +} diff --git a/src/viewer/favicon.svg b/src/viewer/favicon.svg new file mode 100644 index 0000000..3ef799f --- /dev/null +++ b/src/viewer/favicon.svg @@ -0,0 +1 @@ +AM diff --git a/src/viewer/index.html b/src/viewer/index.html new file mode 100644 index 0000000..305397e --- /dev/null +++ b/src/viewer/index.html @@ -0,0 +1,4182 @@ + + + + + + agentmemory viewer + + + + + +
+ +

agentmemory

+ v__AGENTMEMORY_VERSION__ +
+
+ + + live updates off +
+
+ +
+ + + + + + + + + + + + +
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + diff --git a/src/viewer/server.ts b/src/viewer/server.ts new file mode 100644 index 0000000..c80e67f --- /dev/null +++ b/src/viewer/server.ts @@ -0,0 +1,451 @@ +import { + createServer, + type Server, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderViewerDocument } from "./document.js"; +import { timingSafeCompare } from "../auth.js"; + +// Self-host the viewer favicon at /favicon.svg instead of an inline +// data: URI so the viewer CSP can stay tight at `img-src 'self'`. +// Mirrors loadViewerTemplate() in document.ts — same candidate paths so +// it resolves both from source (vitest) and from dist/ (npm run start). +function loadViewerFavicon(): Buffer | null { + const base = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(base, "..", "src", "viewer", "favicon.svg"), + join(base, "..", "viewer", "favicon.svg"), + join(base, "viewer", "favicon.svg"), + ]; + for (const path of candidates) { + try { + return readFileSync(path); + } catch {} + } + return null; +} + +// Favicon is static — load once at module init instead of one synchronous +// disk read per /favicon.svg request. +const VIEWER_FAVICON: Buffer | null = loadViewerFavicon(); + +const ALLOWED_ORIGINS = ( + process.env.VIEWER_ALLOWED_ORIGINS || + "http://localhost:3111,http://localhost:3113,http://127.0.0.1:3111,http://127.0.0.1:3113" +) + .split(",") + .map((o) => o.trim()); + +// Hosts the viewer will accept in the Host header. Restricting this is the +// defence against DNS rebinding: a browser visiting `attacker.com` whose +// authoritative DNS rebinds to 127.0.0.1 hits the viewer's listening socket +// directly, the Origin header reads `http://attacker.com` (same-origin from +// the browser's perspective on a same-port attacker page, so no preflight +// fires), and the request body is whatever the page wants. The viewer +// proxies it to the local REST API with the AGENTMEMORY_SECRET bearer +// attached, so the response stream is fully privileged. Rejecting any Host +// not in this allowlist closes that path before the proxy runs. +// +// Explicit override via VIEWER_ALLOWED_HOSTS for the rare case of a +// reverse-proxy in front of the viewer; defaults are computed from the +// listen port at server-create time. Read on each call (no module-level +// caching) so tests can rotate the env var between startViewerServer() +// and the first request. Note: `buildAllowedHosts` only re-runs on a +// cache miss for the in-process `allowedHosts` set, so production env +// changes after the first request require a restart. +function readAllowedHostsOverride(): string[] { + return (process.env.VIEWER_ALLOWED_HOSTS || "") + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); +} + +export function resolveViewerHost(): string { + return process.env.AGENTMEMORY_VIEWER_HOST?.trim() || "127.0.0.1"; +} + +export function isLoopbackHost(host: string): boolean { + const h = host.trim().toLowerCase(); + return h === "127.0.0.1" || h === "::1" || h === "localhost"; +} + +export function buildAllowedHosts( + origins: string[], + listenPort: number, + bindHost: string = "127.0.0.1", +): Set { + const hosts = new Set(); + // When bind is loopback the listening socket is unreachable from the + // network, so it's safe to seed the allowlist from the CORS origins + // (which by default are localhost-based) plus the standard loopback + // hostnames on the actual listen port. When bind is non-loopback the + // listening socket is reachable from anywhere TCP can reach the + // process, and any of those loopback names becomes a spoofable Host + // header — so only explicit VIEWER_ALLOWED_HOSTS entries are trusted. + if (isLoopbackHost(bindHost)) { + for (const o of origins) { + try { + const parsed = new URL(o); + if (parsed.host) hosts.add(parsed.host.toLowerCase()); + } catch { + // Skip invalid origin entries — the existing CORS path already + // tolerates them by simply not matching; mirror that here. + } + } + hosts.add(`localhost:${listenPort}`); + hosts.add(`127.0.0.1:${listenPort}`); + hosts.add(`[::1]:${listenPort}`); + } + for (const h of readAllowedHostsOverride()) hosts.add(h); + return hosts; +} + +export function isHostAllowed( + headerHost: string | string[] | undefined, + allowed: Set, +): boolean { + if (typeof headerHost !== "string") return false; + const lower = headerHost.toLowerCase().trim(); + if (!lower) return false; + return allowed.has(lower); +} + +// When bind is non-loopback the viewer is a bearer-authorized proxy +// reachable from the network, so every request that would forward +// upstream must also present the same bearer. Static routes (HTML, +// favicon) stay open so a browser can fetch the shell — the JS inside +// then has to provide a bearer for the API calls. +export function requireInboundBearer( + authHeader: string | string[] | undefined, + secret: string, +): boolean { + if (typeof authHeader !== "string") return false; + const match = /^Bearer\s+(\S+)\s*$/i.exec(authHeader); + if (!match) return false; + return timingSafeCompare(match[1], secret); +} + +function corsHeaders(req: IncomingMessage): Record { + const origin = req.headers.origin || ""; + const allowed = ALLOWED_ORIGINS.includes(origin) + ? origin + : ALLOWED_ORIGINS[0]; + return { + "Access-Control-Allow-Origin": allowed, + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + Vary: "Origin", + }; +} + +function json( + res: ServerResponse, + status: number, + data: unknown, + req?: IncomingMessage, +): void { + const body = JSON.stringify(data); + const cors = req + ? corsHeaders(req) + : { "Access-Control-Allow-Origin": ALLOWED_ORIGINS[0], Vary: "Origin" }; + res.writeHead(status, { ...cors, "Content-Type": "application/json" }); + res.end(body); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ""; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > 1_000_000) { + req.destroy(); + reject(new Error("too large")); + return; + } + data += chunk.toString(); + }); + req.on("end", () => resolve(data)); + req.on("error", reject); + }); +} + +const MAX_VIEWER_PORT_RETRIES = 10; + +let boundViewerPort: number | null = null; +let viewerSkipped = false; + +export function getBoundViewerPort(): number | null { + return boundViewerPort; +} +export function getViewerSkipped(): boolean { + return viewerSkipped; +} + +export class ViewerConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ViewerConfigError"; + } +} + +export function startViewerServer( + port: number, + _kv: unknown, + _sdk: unknown, + secret?: string, + restPort?: number, +): Server { + // Reset exported runtime state for each start attempt. + boundViewerPort = null; + viewerSkipped = false; + + const resolvedRestPort = restPort ?? port - 2; + const requestedPort = port; + const host = resolveViewerHost(); + let inboundSecret: string | null = null; + + // Non-loopback bind turns the viewer into a network-reachable + // bearer-authorized proxy. Refuse to start unless the operator has + // both an inbound secret to authenticate callers against and an + // explicit Host header allowlist; otherwise the listening socket + // becomes an open relay to the local REST API. + if (!isLoopbackHost(host)) { + if (!secret) { + throw new ViewerConfigError( + `AGENTMEMORY_VIEWER_HOST=${host} requires AGENTMEMORY_SECRET to be set so the viewer can validate inbound bearer tokens. To fix: unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind, or set AGENTMEMORY_SECRET. For Fly images, it is printed on first boot; see deploy/fly/README.md.`, + ); + } + if (readAllowedHostsOverride().length === 0) { + throw new ViewerConfigError( + `AGENTMEMORY_VIEWER_HOST=${host} requires VIEWER_ALLOWED_HOSTS because non-loopback viewer binds only trust explicit Host headers. To fix: set VIEWER_ALLOWED_HOSTS to a comma-separated list of trusted Host header values (e.g. "localhost:3113" for fly proxy), or unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind.`, + ); + } + inboundSecret = secret; + } + + // Computed lazily on first request — `port` may be 0 here (OS-assigned) + // or the EADDRINUSE retry loop below may bump us to a different port, + // so we read the actual bound port from server.address() on first hit. + let allowedHosts: Set | null = null; + + const server = createServer(async (req, res) => { + if (!allowedHosts) { + const addr = server.address(); + const actualPort = + addr && typeof addr === "object" && "port" in addr + ? (addr.port as number) + : port; + allowedHosts = buildAllowedHosts(ALLOWED_ORIGINS, actualPort, host); + } + if (!isHostAllowed(req.headers.host, allowedHosts)) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("forbidden host"); + return; + } + + const raw = req.url || "/"; + const qIdx = raw.indexOf("?"); + const pathname = qIdx >= 0 ? raw.slice(0, qIdx) : raw; + const qs = qIdx >= 0 ? raw.slice(qIdx + 1) : ""; + const method = req.method || "GET"; + + if (method === "OPTIONS") { + res.writeHead(204, { + ...corsHeaders(req), + "Access-Control-Max-Age": "86400", + }); + res.end(); + return; + } + + if ( + method === "GET" && + (pathname === "/" || + pathname === "/viewer" || + pathname === "/agentmemory/viewer") + ) { + const rendered = renderViewerDocument(); + if (rendered.found) { + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": rendered.csp, + "Cache-Control": "no-cache", + }); + res.end(rendered.html); + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("viewer not found"); + return; + } + + if (method === "GET" && pathname === "/favicon.svg") { + if (VIEWER_FAVICON) { + res.writeHead(200, { + "Content-Type": "image/svg+xml", + "Cache-Control": "public, max-age=3600", + }); + res.end(VIEWER_FAVICON); + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("favicon not found"); + return; + } + + if ( + inboundSecret !== null && + !requireInboundBearer(req.headers.authorization, inboundSecret) + ) { + res.writeHead(401, { + "Content-Type": "text/plain", + "WWW-Authenticate": 'Bearer realm="agentmemory-viewer"', + }); + res.end("unauthorized"); + return; + } + + try { + await proxyToRestApi(resolvedRestPort, pathname, qs, method, req, res, secret); + } catch (err) { + console.error(`[viewer] proxy error on ${method} ${pathname}:`, err); + json(res, 502, { error: "upstream error" }, req); + } + }); + + let attempt = 0; + let currentPort = requestedPort; + + const tryListen = (): void => { + server.listen(currentPort, host); + }; + + server.on("listening", () => { + const addr = server.address(); + // `currentPort` is the value passed to `listen()` and stays 0 for + // ephemeral-port callers (tests, port=0). `server.address()` exposes + // the OS-assigned port — log that so the startup line is accurate. + const actualPort = + addr && typeof addr === "object" && "port" in addr + ? addr.port + : currentPort; + boundViewerPort = actualPort; + viewerSkipped = false; + if (inboundSecret !== null) { + const allowedHosts = readAllowedHostsOverride().join(", "); + console.log( + `[agentmemory] Viewer: http://localhost:${actualPort} (bound to ${host}; inbound Bearer required; allowed Host headers: ${allowedHosts})`, + ); + return; + } + if (actualPort === requestedPort) { + console.log(`[agentmemory] Viewer: http://localhost:${actualPort}`); + } else { + console.log( + `[agentmemory] Viewer started on http://localhost:${actualPort} (fallback from ${requestedPort})`, + ); + } + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + if ( + err.code === "EADDRINUSE" && + inboundSecret === null && + attempt < MAX_VIEWER_PORT_RETRIES + ) { + attempt++; + currentPort = requestedPort + attempt; + setImmediate(tryListen); + return; + } + if (err.code === "EADDRINUSE") { + boundViewerPort = null; + viewerSkipped = true; + if (inboundSecret !== null) { + console.warn( + `[agentmemory] Viewer port ${requestedPort} is in use while bound to ${host}; not retrying because non-loopback viewer binds require VIEWER_ALLOWED_HOSTS to match the exact port. Free the port, choose another viewer port, or unset AGENTMEMORY_VIEWER_HOST to keep the safe loopback bind.`, + ); + } else { + console.warn( + `[agentmemory] Viewer ports ${requestedPort}-${requestedPort + MAX_VIEWER_PORT_RETRIES} all in use, skipping viewer.`, + ); + } + } else { + boundViewerPort = null; + viewerSkipped = true; + console.error(`[agentmemory] Viewer error:`, err.message); + } + }); + + tryListen(); + + return server; +} + +async function proxyToRestApi( + restPort: number, + pathname: string, + qs: string, + method: string, + req: IncomingMessage, + res: ServerResponse, + secret?: string, +): Promise { + const upstreamPath = pathname.startsWith("/agentmemory/") + ? pathname + : `/agentmemory${pathname.startsWith("/") ? pathname : "/" + pathname}`; + + const upstreamUrl = `http://127.0.0.1:${restPort}${upstreamPath}${qs ? "?" + qs : ""}`; + + const headers: Record = {}; + if (secret) { + headers["Authorization"] = `Bearer ${secret}`; + } + const ct = req.headers["content-type"]; + if (ct) { + headers["Content-Type"] = ct; + } + + let body: string | undefined; + if (method === "POST" || method === "PUT" || method === "DELETE" || method === "PATCH") { + body = await readBody(req); + } + + const controller = new AbortController(); + const fetchTimeout = setTimeout(() => controller.abort(), 10000); + let upstream: Response; + try { + upstream = await fetch(upstreamUrl, { + method, + headers, + body: body || undefined, + signal: controller.signal, + }); + clearTimeout(fetchTimeout); + } catch (err) { + clearTimeout(fetchTimeout); + if (err instanceof Error && err.name === "AbortError") { + json(res, 504, { error: "upstream timeout" }, req); + return; + } + throw err; + } + + const cors = corsHeaders(req); + const responseBody = await upstream.text(); + const responseHeaders: Record = { + ...cors, + }; + const upstreamCt = upstream.headers.get("content-type"); + if (upstreamCt) { + responseHeaders["Content-Type"] = upstreamCt; + } + + res.writeHead(upstream.status, responseHeaders); + res.end(responseBody); +} diff --git a/src/xenova.d.ts b/src/xenova.d.ts new file mode 100644 index 0000000..7abd4b0 --- /dev/null +++ b/src/xenova.d.ts @@ -0,0 +1,3 @@ +declare module "@xenova/transformers" { + export function pipeline(task: string, model: string): Promise; +} diff --git a/test/access-tracker.test.ts b/test/access-tracker.test.ts new file mode 100644 index 0000000..35fca78 --- /dev/null +++ b/test/access-tracker.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; +} + +describe("access-tracker", () => { + it("getAccessLog returns empty log for unknown id", async () => { + const { getAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + const log = await getAccessLog(kv as never, "mem_xyz"); + expect(log).toEqual({ memoryId: "mem_xyz", count: 0, lastAt: "", recent: [] }); + }); + + it("recordAccess increments count and lastAt", async () => { + const { recordAccess, getAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + await recordAccess(kv as never, "mem_a", 1_000_000); + await recordAccess(kv as never, "mem_a", 2_000_000); + await recordAccess(kv as never, "mem_a", 3_000_000); + + const log = await getAccessLog(kv as never, "mem_a"); + expect(log.count).toBe(3); + expect(log.recent).toEqual([1_000_000, 2_000_000, 3_000_000]); + expect(log.lastAt).toBe(new Date(3_000_000).toISOString()); + }); + + it("recent[] is bounded to last 20 entries", async () => { + const { recordAccess, getAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + for (let i = 1; i <= 50; i++) { + await recordAccess(kv as never, "mem_b", i * 1000); + } + const log = await getAccessLog(kv as never, "mem_b"); + expect(log.count).toBe(50); + expect(log.recent.length).toBe(20); + // Should be the LAST 20: 31_000..50_000 + expect(log.recent[0]).toBe(31_000); + expect(log.recent[19]).toBe(50_000); + }); + + it("recordAccessBatch deduplicates and writes once per id", async () => { + const { recordAccessBatch, getAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + await recordAccessBatch( + kv as never, + ["mem_a", "mem_b", "mem_a", "mem_b", "mem_c"], + 5_000_000, + ); + expect((await getAccessLog(kv as never, "mem_a")).count).toBe(1); + expect((await getAccessLog(kv as never, "mem_b")).count).toBe(1); + expect((await getAccessLog(kv as never, "mem_c")).count).toBe(1); + }); + + it("recordAccess swallows kv.set errors (must not break reads)", async () => { + const { recordAccess } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + kv.set = (async () => { + throw new Error("boom"); + }) as never; + await expect(recordAccess(kv as never, "mem_a")).resolves.toBeUndefined(); + }); + + it("concurrent recordAccess calls do not lose increments (keyed mutex)", async () => { + const { recordAccess, getAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + await Promise.all( + Array.from({ length: 25 }, (_, i) => + recordAccess(kv as never, "mem_race", i * 100), + ), + ); + const log = await getAccessLog(kv as never, "mem_race"); + expect(log.count).toBe(25); + }); + + it("recordAccessBatch: a single failing id does not block siblings", async () => { + const { recordAccessBatch, getAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + const realSet = kv.set.bind(kv); + kv.set = (async (scope: string, key: string, val: unknown) => { + if (key === "mem_slow") throw new Error("write failed"); + return realSet(scope, key, val); + }) as never; + + await recordAccessBatch( + kv as never, + ["mem_slow", "mem_fast_a", "mem_fast_b"], + 1_000_000, + ); + expect((await getAccessLog(kv as never, "mem_fast_a")).count).toBe(1); + expect((await getAccessLog(kv as never, "mem_fast_b")).count).toBe(1); + expect((await getAccessLog(kv as never, "mem_slow")).count).toBe(0); + }); + + it("ignores empty / falsy memory ids", async () => { + const { recordAccess, recordAccessBatch } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + await recordAccess(kv as never, ""); + await recordAccessBatch(kv as never, ["", "mem_x", ""]); + expect(kv.store.get("mem:access")?.has("")).toBeFalsy(); + expect(kv.store.get("mem:access")?.get("mem_x")).toBeTruthy(); + }); + + it("deleteAccessLog removes the target entry and leaves siblings intact", async () => { + const { recordAccess, deleteAccessLog, getAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + await recordAccess(kv as never, "mem_a"); + await recordAccess(kv as never, "mem_b"); + + await deleteAccessLog(kv as never, "mem_a"); + + expect(kv.store.get("mem:access")?.has("mem_a")).toBe(false); + expect((await getAccessLog(kv as never, "mem_b")).count).toBe(1); + }); + + it("deleteAccessLog is a no-op for unknown ids and empty ids", async () => { + const { deleteAccessLog, recordAccess } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + await recordAccess(kv as never, "mem_keep"); + + await deleteAccessLog(kv as never, ""); + await deleteAccessLog(kv as never, "mem_unknown"); + + expect(kv.store.get("mem:access")?.has("mem_keep")).toBe(true); + }); + + it("deleteAccessLog swallows kv.delete errors", async () => { + const { deleteAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const kv = mockKV(); + kv.delete = (async () => { + throw new Error("boom"); + }) as never; + + await expect( + deleteAccessLog(kv as never, "mem_x"), + ).resolves.toBeUndefined(); + }); +}); + +describe("normalizeAccessLog", () => { + it("returns a well-formed empty log for nullish / non-object input", async () => { + const { normalizeAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const log = normalizeAccessLog(null); + expect(log).toEqual({ + memoryId: "", + count: 0, + lastAt: "", + recent: [], + }); + expect(normalizeAccessLog(undefined).count).toBe(0); + expect(normalizeAccessLog("garbage").count).toBe(0); + }); + + it("coerces count to a non-negative integer", async () => { + const { normalizeAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + expect(normalizeAccessLog({ count: -5 }).count).toBe(0); + expect(normalizeAccessLog({ count: 3.7 }).count).toBe(3); + expect(normalizeAccessLog({ count: NaN }).count).toBe(0); + expect(normalizeAccessLog({ count: "123" }).count).toBe(0); + }); + + it("preserves large lifetime counts (NOT capped at ring buffer size)", async () => { + const { normalizeAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const log = normalizeAccessLog({ memoryId: "m", count: 500, recent: [1] }); + expect(log.count).toBe(500); + }); + + it("truncates recent[] to the last 20 entries and drops non-finite values", async () => { + const { normalizeAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const input = Array.from({ length: 40 }, (_, i) => i * 1000); + const withGarbage = [...input, NaN, Infinity, "bad" as unknown as number]; + const log = normalizeAccessLog({ recent: withGarbage }); + expect(log.recent.length).toBe(20); + expect(log.recent[0]).toBe(20_000); + expect(log.recent[19]).toBe(39_000); + }); + + it("count is at least recent.length when count < recent.length", async () => { + const { normalizeAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + const log = normalizeAccessLog({ + count: 2, + recent: [1, 2, 3, 4, 5], + }); + expect(log.count).toBeGreaterThanOrEqual(5); + }); + + it("fills in memoryId only when field is a string", async () => { + const { normalizeAccessLog } = await import( + "../src/functions/access-tracker.js" + ); + expect(normalizeAccessLog({ memoryId: "mem_x" }).memoryId).toBe("mem_x"); + expect(normalizeAccessLog({ memoryId: 42 }).memoryId).toBe(""); + }); +}); diff --git a/test/actions.test.ts b/test/actions.test.ts new file mode 100644 index 0000000..156ba79 --- /dev/null +++ b/test/actions.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerActionsFunction } from "../src/functions/actions.js"; +import type { Action, ActionEdge } from "../src/types.js"; +import { mockKV, mockSdk } from "./helpers/mocks.js"; + +describe("Actions Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerActionsFunction(sdk as never, kv as never); + }); + + describe("mem::action-create", () => { + it("creates an action with valid data", async () => { + const result = (await sdk.trigger("mem::action-create", { + title: "Fix login bug", + description: "Users cannot log in with SSO", + priority: 7, + createdBy: "agent-1", + project: "webapp", + tags: ["bug", "auth"], + })) as { success: boolean; action: Action; edges: ActionEdge[] }; + + expect(result.success).toBe(true); + expect(result.action.id).toMatch(/^act_/); + expect(result.action.title).toBe("Fix login bug"); + expect(result.action.description).toBe("Users cannot log in with SSO"); + expect(result.action.status).toBe("pending"); + expect(result.action.priority).toBe(7); + expect(result.action.createdBy).toBe("agent-1"); + expect(result.action.project).toBe("webapp"); + expect(result.action.tags).toEqual(["bug", "auth"]); + expect(result.action.createdAt).toBeDefined(); + expect(result.action.updatedAt).toBeDefined(); + expect(result.edges).toEqual([]); + }); + + it("returns error when title is missing", async () => { + const result = (await sdk.trigger("mem::action-create", { + description: "No title provided", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("title is required"); + }); + + it("clamps priority 0 to default 5 (falsy fallback)", async () => { + const result = (await sdk.trigger("mem::action-create", { + title: "Zero priority task", + priority: 0, + })) as { success: boolean; action: Action }; + + expect(result.success).toBe(true); + expect(result.action.priority).toBe(5); + }); + + it("clamps negative priority to 1", async () => { + const result = (await sdk.trigger("mem::action-create", { + title: "Negative priority task", + priority: -3, + })) as { success: boolean; action: Action }; + + expect(result.success).toBe(true); + expect(result.action.priority).toBe(1); + }); + + it("clamps priority 15 to 10", async () => { + const result = (await sdk.trigger("mem::action-create", { + title: "High priority task", + priority: 15, + })) as { success: boolean; action: Action }; + + expect(result.success).toBe(true); + expect(result.action.priority).toBe(10); + }); + + it("validates parent action exists", async () => { + const result = (await sdk.trigger("mem::action-create", { + title: "Child task", + parentId: "nonexistent_parent", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("parent action not found"); + }); + + it("creates action with valid parent", async () => { + const parentResult = (await sdk.trigger("mem::action-create", { + title: "Parent task", + })) as { success: boolean; action: Action }; + + const childResult = (await sdk.trigger("mem::action-create", { + title: "Child task", + parentId: parentResult.action.id, + })) as { success: boolean; action: Action }; + + expect(childResult.success).toBe(true); + expect(childResult.action.parentId).toBe(parentResult.action.id); + }); + + it("creates inline edges with valid types", async () => { + const targetResult = (await sdk.trigger("mem::action-create", { + title: "Target action", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::action-create", { + title: "Source action", + edges: [ + { type: "requires", targetActionId: targetResult.action.id }, + { type: "unlocks", targetActionId: targetResult.action.id }, + ], + })) as { success: boolean; action: Action; edges: ActionEdge[] }; + + expect(result.success).toBe(true); + expect(result.edges.length).toBe(2); + expect(result.edges[0].id).toMatch(/^ae_/); + expect(result.edges[0].type).toBe("requires"); + expect(result.edges[0].sourceActionId).toBe(result.action.id); + expect(result.edges[0].targetActionId).toBe(targetResult.action.id); + expect(result.edges[1].type).toBe("unlocks"); + }); + + it("returns error for inline edge with invalid type", async () => { + const targetResult = (await sdk.trigger("mem::action-create", { + title: "Target action", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::action-create", { + title: "Source action", + edges: [ + { type: "invalid_type", targetActionId: targetResult.action.id }, + ], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("invalid edge type"); + }); + + it("returns error for inline edge with nonexistent target", async () => { + const result = (await sdk.trigger("mem::action-create", { + title: "Source action", + edges: [{ type: "requires", targetActionId: "nonexistent_id" }], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("target action not found"); + }); + }); + + describe("mem::action-update", () => { + it("updates an action with valid data", async () => { + const createResult = (await sdk.trigger("mem::action-create", { + title: "Original title", + priority: 5, + })) as { success: boolean; action: Action }; + + const updateResult = (await sdk.trigger("mem::action-update", { + actionId: createResult.action.id, + title: "Updated title", + priority: 8, + status: "active", + assignedTo: "agent-2", + tags: ["updated"], + })) as { success: boolean; action: Action }; + + expect(updateResult.success).toBe(true); + expect(updateResult.action.title).toBe("Updated title"); + expect(updateResult.action.priority).toBe(8); + expect(updateResult.action.status).toBe("active"); + expect(updateResult.action.assignedTo).toBe("agent-2"); + expect(updateResult.action.tags).toEqual(["updated"]); + }); + + it("returns error when actionId is missing", async () => { + const result = (await sdk.trigger("mem::action-update", { + title: "no id", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("actionId is required"); + }); + + it("returns error for nonexistent action", async () => { + const result = (await sdk.trigger("mem::action-update", { + actionId: "nonexistent_id", + status: "done", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("action not found"); + }); + + it("propagates completion when status set to done", async () => { + const actionB = (await sdk.trigger("mem::action-create", { + title: "Dependency B", + })) as { success: boolean; action: Action }; + + const actionA = (await sdk.trigger("mem::action-create", { + title: "Action A depends on B", + edges: [{ type: "requires", targetActionId: actionB.action.id }], + })) as { success: boolean; action: Action }; + + await sdk.trigger("mem::action-update", { + actionId: actionA.action.id, + status: "blocked", + }); + + await sdk.trigger("mem::action-update", { + actionId: actionB.action.id, + status: "done", + }); + + const getResult = (await sdk.trigger("mem::action-get", { + actionId: actionA.action.id, + })) as { success: boolean; action: Action }; + + expect(getResult.action.status).toBe("pending"); + }); + }); + + describe("mem::action-edge-create", () => { + it("creates an edge between two actions", async () => { + const source = (await sdk.trigger("mem::action-create", { + title: "Source", + })) as { success: boolean; action: Action }; + + const target = (await sdk.trigger("mem::action-create", { + title: "Target", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::action-edge-create", { + sourceActionId: source.action.id, + targetActionId: target.action.id, + type: "requires", + })) as { success: boolean; edge: ActionEdge }; + + expect(result.success).toBe(true); + expect(result.edge.id).toMatch(/^ae_/); + expect(result.edge.type).toBe("requires"); + expect(result.edge.sourceActionId).toBe(source.action.id); + expect(result.edge.targetActionId).toBe(target.action.id); + expect(result.edge.createdAt).toBeDefined(); + }); + + it("returns error when required fields are missing", async () => { + const result = (await sdk.trigger("mem::action-edge-create", { + sourceActionId: "some_id", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("required"); + }); + + it("returns error for invalid edge type", async () => { + const source = (await sdk.trigger("mem::action-create", { + title: "Source", + })) as { success: boolean; action: Action }; + + const target = (await sdk.trigger("mem::action-create", { + title: "Target", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::action-edge-create", { + sourceActionId: source.action.id, + targetActionId: target.action.id, + type: "invalid_type", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("type must be one of"); + }); + + it("returns error for nonexistent source action", async () => { + const target = (await sdk.trigger("mem::action-create", { + title: "Target", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::action-edge-create", { + sourceActionId: "nonexistent", + targetActionId: target.action.id, + type: "requires", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("source action not found"); + }); + + it("returns error for nonexistent target action", async () => { + const source = (await sdk.trigger("mem::action-create", { + title: "Source", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::action-edge-create", { + sourceActionId: source.action.id, + targetActionId: "nonexistent", + type: "requires", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("target action not found"); + }); + }); + + describe("mem::action-list", () => { + beforeEach(async () => { + await sdk.trigger("mem::action-create", { + title: "Task A", + status: "pending", + project: "alpha", + tags: ["frontend"], + }); + await new Promise((r) => setTimeout(r, 5)); + await sdk.trigger("mem::action-create", { + title: "Task B", + project: "alpha", + tags: ["backend"], + }); + await new Promise((r) => setTimeout(r, 5)); + await sdk.trigger("mem::action-create", { + title: "Task C", + project: "beta", + tags: ["frontend", "backend"], + }); + }); + + it("returns all actions", async () => { + const result = (await sdk.trigger("mem::action-list", {})) as { + success: boolean; + actions: Action[]; + }; + + expect(result.success).toBe(true); + expect(result.actions.length).toBe(3); + }); + + it("filters by status", async () => { + const all = (await sdk.trigger("mem::action-list", {})) as { + actions: Action[]; + }; + const firstAction = all.actions[0]; + + await sdk.trigger("mem::action-update", { + actionId: firstAction.id, + status: "done", + }); + + const result = (await sdk.trigger("mem::action-list", { + status: "done", + })) as { success: boolean; actions: Action[] }; + + expect(result.success).toBe(true); + expect(result.actions.length).toBe(1); + expect(result.actions[0].status).toBe("done"); + }); + + it("filters by project", async () => { + const result = (await sdk.trigger("mem::action-list", { + project: "alpha", + })) as { success: boolean; actions: Action[] }; + + expect(result.success).toBe(true); + expect(result.actions.length).toBe(2); + expect(result.actions.every((a) => a.project === "alpha")).toBe(true); + }); + + it("filters by tags", async () => { + const result = (await sdk.trigger("mem::action-list", { + tags: ["backend"], + })) as { success: boolean; actions: Action[] }; + + expect(result.success).toBe(true); + expect(result.actions.length).toBe(2); + expect( + result.actions.every((a) => a.tags.includes("backend")), + ).toBe(true); + }); + + it("respects limit", async () => { + const result = (await sdk.trigger("mem::action-list", { + limit: 2, + })) as { success: boolean; actions: Action[] }; + + expect(result.success).toBe(true); + expect(result.actions.length).toBe(2); + }); + }); + + describe("mem::action-get", () => { + it("returns action with edges and children", async () => { + const parent = (await sdk.trigger("mem::action-create", { + title: "Parent", + })) as { success: boolean; action: Action }; + + const child = (await sdk.trigger("mem::action-create", { + title: "Child", + parentId: parent.action.id, + })) as { success: boolean; action: Action }; + + const other = (await sdk.trigger("mem::action-create", { + title: "Other", + })) as { success: boolean; action: Action }; + + await sdk.trigger("mem::action-edge-create", { + sourceActionId: parent.action.id, + targetActionId: other.action.id, + type: "unlocks", + }); + + const result = (await sdk.trigger("mem::action-get", { + actionId: parent.action.id, + })) as { + success: boolean; + action: Action; + edges: ActionEdge[]; + children: Action[]; + }; + + expect(result.success).toBe(true); + expect(result.action.id).toBe(parent.action.id); + expect(result.edges.length).toBe(1); + expect(result.edges[0].type).toBe("unlocks"); + expect(result.children.length).toBe(1); + expect(result.children[0].id).toBe(child.action.id); + }); + + it("returns error for missing actionId", async () => { + const result = (await sdk.trigger("mem::action-get", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("actionId is required"); + }); + + it("returns error for nonexistent action", async () => { + const result = (await sdk.trigger("mem::action-get", { + actionId: "nonexistent", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("action not found"); + }); + }); +}); diff --git a/test/agent-id-scope.test.ts b/test/agent-id-scope.test.ts new file mode 100644 index 0000000..b74e5c7 --- /dev/null +++ b/test/agent-id-scope.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// AGENT_ID scope for multi-agent memory isolation. + +describe("loadAgentScope (#554)", () => { + const ORIG = process.env["AGENT_ID"]; + beforeEach(() => { + vi.resetModules(); + delete process.env["AGENT_ID"]; + }); + afterEach(() => { + if (ORIG === undefined) delete process.env["AGENT_ID"]; + else process.env["AGENT_ID"] = ORIG; + }); + + it("returns null when AGENT_ID is unset", async () => { + const { loadAgentScope, getAgentId } = await import("../src/config.js"); + expect(loadAgentScope()).toBeNull(); + expect(getAgentId()).toBeUndefined(); + }); + + it("returns the agentId + scope mode when AGENT_ID is set", async () => { + process.env["AGENT_ID"] = "architect"; + const { loadAgentScope, getAgentId } = await import("../src/config.js"); + expect(loadAgentScope()).toEqual({ agentId: "architect", mode: "shared" }); + expect(getAgentId()).toBe("architect"); + }); + + it("trims whitespace and rejects empty after trim", async () => { + process.env["AGENT_ID"] = " "; + const { loadAgentScope } = await import("../src/config.js"); + expect(loadAgentScope()).toBeNull(); + }); + + it("caps length at 128 chars to keep KV writes well-formed", async () => { + process.env["AGENT_ID"] = "x".repeat(500); + const { getAgentId } = await import("../src/config.js"); + expect(getAgentId()!.length).toBe(128); + }); +}); + +describe("mem::remember stamps agentId on the Memory (#554)", () => { + const ORIG = process.env["AGENT_ID"]; + beforeEach(() => { + vi.resetModules(); + delete process.env["AGENT_ID"]; + }); + afterEach(() => { + if (ORIG === undefined) delete process.env["AGENT_ID"]; + else process.env["AGENT_ID"] = ORIG; + }); + + function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; + } + + function mockSdk() { + const fns = new Map(); + return { + fns, + registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + fns.set(id, fn); + }, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = fns.get(id); + if (fn) return fn(payload); + return null; + }, + }; + } + + it("stamps env AGENT_ID on Memory when no body override", async () => { + process.env["AGENT_ID"] = "developer"; + const { registerRememberFunction } = await import( + "../src/functions/remember.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = (await sdk.trigger("mem::remember", { + content: "prefer async-std over tokio", + type: "preference", + })) as { memory: { id: string; agentId?: string } }; + + expect(result.memory.agentId).toBe("developer"); + }); + + it("body agentId overrides env AGENT_ID", async () => { + process.env["AGENT_ID"] = "architect"; + const { registerRememberFunction } = await import( + "../src/functions/remember.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = (await sdk.trigger("mem::remember", { + content: "use http-cache for github API", + agentId: "reviewer", + })) as { memory: { id: string; agentId?: string } }; + + expect(result.memory.agentId).toBe("reviewer"); + }); + + it("no env, no body → no agentId on Memory (legacy)", async () => { + const { registerRememberFunction } = await import( + "../src/functions/remember.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = (await sdk.trigger("mem::remember", { + content: "legacy unscoped memory", + })) as { memory: { id: string; agentId?: string } }; + + expect(result.memory.agentId).toBeUndefined(); + }); + + it("body agentId is trimmed of leading/trailing whitespace", async () => { + const { registerRememberFunction } = await import( + "../src/functions/remember.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = (await sdk.trigger("mem::remember", { + content: "trim me", + agentId: " architect ", + })) as { memory: { id: string; agentId?: string } }; + + expect(result.memory.agentId).toBe("architect"); + }); + + it("body agentId longer than 128 chars is truncated to 128", async () => { + const { registerRememberFunction } = await import( + "../src/functions/remember.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const long = "x".repeat(500); + const result = (await sdk.trigger("mem::remember", { + content: "cap length", + agentId: long, + })) as { memory: { id: string; agentId?: string } }; + + expect(result.memory.agentId).toBeDefined(); + expect(result.memory.agentId!.length).toBe(128); + expect(result.memory.agentId).toBe("x".repeat(128)); + }); + + it("whitespace-only body agentId falls back to env AGENT_ID", async () => { + process.env["AGENT_ID"] = "developer"; + const { registerRememberFunction } = await import( + "../src/functions/remember.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = (await sdk.trigger("mem::remember", { + content: "fall through to env", + agentId: " ", + })) as { memory: { id: string; agentId?: string } }; + + expect(result.memory.agentId).toBe("developer"); + }); + + it("empty-string body agentId with no env → no agentId stamped", async () => { + const { registerRememberFunction } = await import( + "../src/functions/remember.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = (await sdk.trigger("mem::remember", { + content: "no env no usable body", + agentId: "", + })) as { memory: { id: string; agentId?: string } }; + + expect(result.memory.agentId).toBeUndefined(); + }); +}); + +describe("AGENTMEMORY_AGENT_SCOPE mode (#554)", () => { + const ORIG_ID = process.env["AGENT_ID"]; + const ORIG_MODE = process.env["AGENTMEMORY_AGENT_SCOPE"]; + beforeEach(() => { + vi.resetModules(); + delete process.env["AGENT_ID"]; + delete process.env["AGENTMEMORY_AGENT_SCOPE"]; + }); + afterEach(() => { + if (ORIG_ID === undefined) delete process.env["AGENT_ID"]; + else process.env["AGENT_ID"] = ORIG_ID; + if (ORIG_MODE === undefined) delete process.env["AGENTMEMORY_AGENT_SCOPE"]; + else process.env["AGENTMEMORY_AGENT_SCOPE"] = ORIG_MODE; + }); + + it("defaults to shared mode when AGENT_ID set but scope unset", async () => { + process.env["AGENT_ID"] = "developer"; + const { loadAgentScope, isAgentScopeIsolated } = await import( + "../src/config.js" + ); + expect(loadAgentScope()).toEqual({ + agentId: "developer", + mode: "shared", + }); + expect(isAgentScopeIsolated()).toBe(false); + }); + + it("flips to isolated when AGENTMEMORY_AGENT_SCOPE=isolated", async () => { + process.env["AGENT_ID"] = "developer"; + process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated"; + const { loadAgentScope, isAgentScopeIsolated } = await import( + "../src/config.js" + ); + expect(loadAgentScope()).toEqual({ + agentId: "developer", + mode: "isolated", + }); + expect(isAgentScopeIsolated()).toBe(true); + }); + + it("isolated requires AGENT_ID to also be set (no scope without id)", async () => { + process.env["AGENTMEMORY_AGENT_SCOPE"] = "isolated"; + const { isAgentScopeIsolated } = await import("../src/config.js"); + expect(isAgentScopeIsolated()).toBe(false); + }); + + it("unknown scope values fall back to shared", async () => { + process.env["AGENT_ID"] = "developer"; + process.env["AGENTMEMORY_AGENT_SCOPE"] = "weird"; + const { isAgentScopeIsolated } = await import("../src/config.js"); + expect(isAgentScopeIsolated()).toBe(false); + }); +}); diff --git a/test/agent-isolation-search.test.ts b/test/agent-isolation-search.test.ts new file mode 100644 index 0000000..929407b --- /dev/null +++ b/test/agent-isolation-search.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/keyed-mutex.js", () => ({ + withKeyedLock: (_key: string, fn: () => Promise) => fn(), +})); + +vi.mock("../src/functions/audit.js", () => ({ + recordAudit: vi.fn(), +})); + +vi.mock("../src/functions/access-tracker.js", () => ({ + recordAccessBatch: vi.fn(), + deleteAccessLog: vi.fn(), +})); + +const configState = { + agentId: undefined as string | undefined, + isolated: false, +}; + +vi.mock("../src/config.js", () => ({ + getAgentId: () => configState.agentId, + isAgentScopeIsolated: () => configState.isolated, +})); + +import { + registerSearchFunction, + getSearchIndex, + setIndexPersistence, +} from "../src/functions/search.js"; +import { KV } from "../src/state/schema.js"; +import type { CompressedObservation, Session, SearchResult } from "../src/types.js"; + +function makeMockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeMockSdk() { + const functions = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = + typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = + typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function registered: ${id}`); + return fn(payload); + }, + }; +} + +async function seedTwoAgents(kv: ReturnType) { + const sessionA: Session = { + id: "sess-a", + project: "shared", + cwd: "/work", + startTime: "2026-01-01T00:00:00Z", + type: "code", + } as Session; + const sessionB: Session = { + id: "sess-b", + project: "shared", + cwd: "/work", + startTime: "2026-01-01T00:00:00Z", + type: "code", + } as Session; + await kv.set(KV.sessions, sessionA.id, sessionA); + await kv.set(KV.sessions, sessionB.id, sessionB); + + const obsA: CompressedObservation = { + id: "obs-a-secret", + sessionId: "sess-a", + timestamp: "2026-01-01T01:00:00Z", + type: "user_prompt", + title: "agent A private", + facts: ["SECRET_MARKER value AAA"], + narrative: "agent A wrote a secret", + concepts: ["secret", "private"], + files: [], + importance: 8, + agentId: "agent_a", + } as CompressedObservation; + const obsB: CompressedObservation = { + id: "obs-b-public", + sessionId: "sess-b", + timestamp: "2026-01-01T02:00:00Z", + type: "user_prompt", + title: "agent B note", + facts: ["SECRET_MARKER value BBB"], + narrative: "agent B wrote about the same marker", + concepts: ["secret"], + files: [], + importance: 6, + agentId: "agent_b", + } as CompressedObservation; + await kv.set(KV.observations("sess-a"), obsA.id, obsA); + await kv.set(KV.observations("sess-b"), obsB.id, obsB); + + // Mirror the indexer's behavior so the BM25 path returns both rows. + const idx = getSearchIndex(); + idx.add(obsA); + idx.add(obsB); +} + +describe("mem::search agent-scope isolation (#817 follow-up)", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = makeMockSdk(); + kv = makeMockKV(); + setIndexPersistence(null); + // Reset index between tests. + const idx = getSearchIndex(); + (idx as unknown as { clear?: () => void }).clear?.(); + configState.agentId = undefined; + configState.isolated = false; + registerSearchFunction(sdk as never, kv as never); + }); + + it("isolated mode + env AGENT_ID excludes other agent's observations", async () => { + configState.isolated = true; + configState.agentId = "agent_a"; + await seedTwoAgents(kv); + + const result = (await sdk.trigger("mem::search", { + query: "SECRET_MARKER", + limit: 10, + })) as { results: SearchResult[] }; + expect(result.results.length).toBeGreaterThan(0); + for (const r of result.results) { + expect(r.observation.agentId).toBe("agent_a"); + } + expect(result.results.find((r) => r.observation.id === "obs-b-public")).toBeUndefined(); + }); + + it('isolated mode + agentId: "*" wildcard bypasses and returns both agents', async () => { + configState.isolated = true; + configState.agentId = "agent_a"; + await seedTwoAgents(kv); + + const result = (await sdk.trigger("mem::search", { + query: "SECRET_MARKER", + limit: 10, + agentId: "*", + })) as { results: SearchResult[] }; + const ids = result.results.map((r) => r.observation.id); + expect(ids).toContain("obs-a-secret"); + expect(ids).toContain("obs-b-public"); + }); + + it("isolated mode with no AGENT_ID fails closed (throws), does not leak", async () => { + configState.isolated = true; + configState.agentId = undefined; + await seedTwoAgents(kv); + + await expect( + sdk.trigger("mem::search", { query: "SECRET_MARKER", limit: 10 }), + ).rejects.toThrow(/AGENTMEMORY_AGENT_SCOPE=isolated/); + }); + + it("non-isolated mode (default) returns all rows regardless of agentId", async () => { + configState.isolated = false; + configState.agentId = undefined; + await seedTwoAgents(kv); + + const result = (await sdk.trigger("mem::search", { + query: "SECRET_MARKER", + limit: 10, + })) as { results: SearchResult[] }; + const ids = result.results.map((r) => r.observation.id); + expect(ids).toContain("obs-a-secret"); + expect(ids).toContain("obs-b-public"); + }); +}); diff --git a/test/agent-sdk-provider.test.ts b/test/agent-sdk-provider.test.ts new file mode 100644 index 0000000..bab17b9 --- /dev/null +++ b/test/agent-sdk-provider.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// #781: concurrent siblings on the agent-sdk provider used to bail out +// empty because the recursion guard mutated process.env synchronously +// before the first await. With the guard scoped to AsyncLocalStorage, +// each sibling runs in its own context and receives the real SDK result. + +// vi.mock is hoisted above module-scope `const`/`let`, so the factory's +// closure can't safely reference non-hoisted bindings. Use vi.hoisted to +// declare the mock's mutable state alongside the mock itself. +const state = vi.hoisted(() => ({ + queryCalls: [] as Array<{ systemPrompt: string; userPrompt: string }>, + mockResult: "ok" as + | string + | ((systemPrompt: string, userPrompt: string) => string), +})); + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: ({ + prompt, + options, + }: { + prompt: string; + options: { systemPrompt: string }; + }) => { + state.queryCalls.push({ systemPrompt: options.systemPrompt, userPrompt: prompt }); + async function* gen() { + const value = + typeof state.mockResult === "function" + ? await state.mockResult(options.systemPrompt, prompt) + : state.mockResult; + yield { type: "result", result: value } as { type: "result"; result: string }; + } + return gen(); + }, +})); + +import { AgentSDKProvider } from "../src/providers/agent-sdk.js"; + +describe("AgentSDKProvider recursion guard (#781)", () => { + beforeEach(() => { + state.queryCalls.length = 0; + state.mockResult = "ok"; + delete process.env.AGENTMEMORY_SDK_CHILD; + }); + + afterEach(() => { + delete process.env.AGENTMEMORY_SDK_CHILD; + }); + + it("concurrent summarize calls each return the SDK result (no empty siblings)", async () => { + const provider = new AgentSDKProvider(); + + const results = await Promise.all([ + provider.summarize("sys", "chunk 1"), + provider.summarize("sys", "chunk 2"), + provider.summarize("sys", "chunk 3"), + provider.summarize("sys", "chunk 4"), + ]); + + expect(results).toEqual([ + "ok", + "ok", + "ok", + "ok", + ]); + expect(state.queryCalls.length).toBe(4); + expect(state.queryCalls.map((c) => c.userPrompt)).toEqual([ + "chunk 1", + "chunk 2", + "chunk 3", + "chunk 4", + ]); + }); + + it("compress and summarize share the same guard scope without interfering", async () => { + const provider = new AgentSDKProvider(); + + const [a, b, c] = await Promise.all([ + provider.summarize("sys", "s1"), + provider.compress("sys", "c1"), + provider.summarize("sys", "s2"), + ]); + + expect(a).toBe("ok"); + expect(b).toBe("ok"); + expect(c).toBe("ok"); + expect(state.queryCalls.length).toBe(3); + }); + + it("sets AGENTMEMORY_SDK_CHILD=1 while inside the SDK call (so spawned subprocesses inherit it)", async () => { + const provider = new AgentSDKProvider(); + let observedEnv: string | undefined; + + state.mockResult = (sysPrompt, _userPrompt) => { + observedEnv = process.env.AGENTMEMORY_SDK_CHILD; + return `${sysPrompt}`; + }; + + expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined(); + await provider.summarize("sys", "user"); + expect(observedEnv).toBe("1"); + expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined(); + }); + + it("restores AGENTMEMORY_SDK_CHILD to its prior value after the call", async () => { + const provider = new AgentSDKProvider(); + process.env.AGENTMEMORY_SDK_CHILD = "prev-value"; + + await provider.summarize("sys", "user"); + + expect(process.env.AGENTMEMORY_SDK_CHILD).toBe("prev-value"); + }); + + it("keeps AGENTMEMORY_SDK_CHILD=1 for the full overlap of concurrent calls", async () => { + const provider = new AgentSDKProvider(); + // Allow the calls to overlap: each call records the env value it + // saw, then a tick later records it again. With a refcounted guard + // both observations on both calls should see "1"; with the old + // per-call snapshot one call's restore would null the env while + // the sibling is still mid-flight. + const observations: Array<{ id: string; phase: string; env: string | undefined }> = []; + + state.mockResult = async (sysPrompt, _user) => { + observations.push({ id: sysPrompt, phase: "enter", env: process.env.AGENTMEMORY_SDK_CHILD }); + await new Promise((resolve) => setTimeout(resolve, 5)); + observations.push({ id: sysPrompt, phase: "exit", env: process.env.AGENTMEMORY_SDK_CHILD }); + return `${sysPrompt}`; + }; + + await Promise.all([ + provider.summarize("a", "x"), + provider.summarize("b", "y"), + provider.summarize("c", "z"), + ]); + + expect(observations.length).toBe(6); + for (const o of observations) { + expect(o.env).toBe("1"); + } + expect(process.env.AGENTMEMORY_SDK_CHILD).toBeUndefined(); + }); + + it("genuine re-entry (an inner call inside the same async tree) still degrades to empty", async () => { + const provider = new AgentSDKProvider(); + let innerResult = "not-set"; + + state.mockResult = async (_sys, _user) => { + // Simulate the SDK callback re-entering the provider while the + // outer call is still active. The ALS frame is active here, so + // the inner call must return "" to break the recursion. + innerResult = await provider.summarize("sys-inner", "user-inner"); + return "outer"; + }; + + const outer = await provider.summarize("sys", "user"); + expect(outer).toBe("outer"); + expect(innerResult).toBe(""); + }); +}); diff --git a/test/audit.test.ts b/test/audit.test.ts new file mode 100644 index 0000000..12cb661 --- /dev/null +++ b/test/audit.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { recordAudit, queryAudit } from "../src/functions/audit.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +describe("Audit Functions", () => { + let kv: ReturnType; + + beforeEach(() => { + kv = mockKV(); + }); + + it("recordAudit creates an entry with proper fields", async () => { + const entry = await recordAudit( + kv as never, + "observe", + "mem::compress", + ["obs_1", "obs_2"], + { count: 2 }, + 0.85, + "user-1", + ); + + expect(entry.id).toMatch(/^aud_/); + expect(entry.timestamp).toBeDefined(); + expect(entry.operation).toBe("observe"); + expect(entry.functionId).toBe("mem::compress"); + expect(entry.targetIds).toEqual(["obs_1", "obs_2"]); + expect(entry.details).toEqual({ count: 2 }); + expect(entry.qualityScore).toBe(0.85); + expect(entry.userId).toBe("user-1"); + }); + + it("queryAudit returns entries sorted by timestamp desc", async () => { + await recordAudit(kv as never, "observe", "fn1", ["a"], {}); + await new Promise((r) => setTimeout(r, 10)); + await recordAudit(kv as never, "delete", "fn2", ["b"], {}); + + const entries = await queryAudit(kv as never); + expect(entries.length).toBe(2); + expect( + new Date(entries[0].timestamp).getTime(), + ).toBeGreaterThanOrEqual(new Date(entries[1].timestamp).getTime()); + }); + + it("queryAudit filters by operation", async () => { + await recordAudit(kv as never, "observe", "fn1", [], {}); + await recordAudit(kv as never, "delete", "fn2", [], {}); + await recordAudit(kv as never, "observe", "fn3", [], {}); + + const entries = await queryAudit(kv as never, { operation: "observe" }); + expect(entries.length).toBe(2); + expect(entries.every((e) => e.operation === "observe")).toBe(true); + }); + + it("queryAudit filters by dateFrom/dateTo", async () => { + const early = await recordAudit(kv as never, "observe", "fn1", [], {}); + await new Promise((r) => setTimeout(r, 20)); + const late = await recordAudit(kv as never, "delete", "fn2", [], {}); + + const entries = await queryAudit(kv as never, { + dateFrom: late.timestamp, + }); + expect(entries.length).toBe(1); + expect(entries[0].operation).toBe("delete"); + + const entriesBefore = await queryAudit(kv as never, { + dateTo: early.timestamp, + }); + expect(entriesBefore.length).toBe(1); + expect(entriesBefore[0].operation).toBe("observe"); + }); + + it("queryAudit respects limit", async () => { + for (let i = 0; i < 10; i++) { + await recordAudit(kv as never, "observe", `fn${i}`, [], {}); + } + + const entries = await queryAudit(kv as never, { limit: 3 }); + expect(entries.length).toBe(3); + }); +}); diff --git a/test/auto-compress.test.ts b/test/auto-compress.test.ts new file mode 100644 index 0000000..fdcced8 --- /dev/null +++ b/test/auto-compress.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { RawObservation } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const fns = new Map(); + const triggered: Array<{ id: string; data: unknown }> = []; + return { + fns, + triggered, + registerFunction: ( + idOrOpts: string | { id: string }, + fn: Function, + _options?: Record, + ) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + fns.set(id, fn); + }, + trigger: async ( + idOrInput: + | string + | { function_id: string; payload: unknown; action?: unknown }, + data?: unknown, + ) => { + const id = + typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = + typeof idOrInput === "string" ? data : idOrInput.payload; + triggered.push({ id, data: payload }); + const fn = fns.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +function validPayload(overrides: Partial> = {}) { + return { + sessionId: "ses_test", + hookType: "post_tool_use", + timestamp: new Date().toISOString(), + data: { + tool_name: "Read", + tool_input: { file_path: "src/foo.ts" }, + tool_output: "file contents here", + }, + ...overrides, + }; +} + +describe("mem::observe auto-compress gate (#138)", () => { + beforeEach(() => { + // Reset module cache so observe.js re-imports config.js with the + // fresh AGENTMEMORY_AUTO_COMPRESS env state. Without this, a later + // test that sets the env var can be undermined by cached module + // state from an earlier test (and vice versa). + vi.resetModules(); + delete process.env["AGENTMEMORY_AUTO_COMPRESS"]; + }); + afterEach(() => { + delete process.env["AGENTMEMORY_AUTO_COMPRESS"]; + }); + + it("default (AGENTMEMORY_AUTO_COMPRESS unset): does NOT fire mem::compress", async () => { + const { registerObserveFunction } = await import( + "../src/functions/observe.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never); + + const result = (await sdk.trigger( + "mem::observe", + validPayload(), + )) as { observationId: string }; + + expect(result.observationId).toBeTruthy(); + const compressCalls = sdk.triggered.filter((t) => t.id === "mem::compress"); + expect(compressCalls).toHaveLength(0); + }); + + it("default: stores a synthetic CompressedObservation with the raw-derived fields", async () => { + const { registerObserveFunction } = await import( + "../src/functions/observe.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never); + + const payload = validPayload(); + await sdk.trigger("mem::observe", payload); + + const scope = `mem:obs:${payload.sessionId}`; + const stored = kv.store.get(scope); + expect(stored).toBeDefined(); + expect(stored!.size).toBe(1); + const [entry] = Array.from(stored!.values()); + const obs = entry as { + type: string; + title: string; + files: string[]; + confidence: number; + }; + expect(obs.type).toBe("file_read"); + expect(obs.title).toBe("Read"); + expect(obs.files).toContain("src/foo.ts"); + expect(obs.confidence).toBe(0.3); + }); + + it("AGENTMEMORY_AUTO_COMPRESS=true: fires mem::compress exactly once", async () => { + process.env["AGENTMEMORY_AUTO_COMPRESS"] = "true"; + const { registerObserveFunction } = await import( + "../src/functions/observe.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never); + + await sdk.trigger("mem::observe", validPayload()); + + const compressCalls = sdk.triggered.filter((t) => t.id === "mem::compress"); + expect(compressCalls).toHaveLength(1); + }); + + it("AGENTMEMORY_AUTO_COMPRESS=false explicitly: does NOT fire mem::compress", async () => { + process.env["AGENTMEMORY_AUTO_COMPRESS"] = "false"; + const { registerObserveFunction } = await import( + "../src/functions/observe.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never); + + await sdk.trigger("mem::observe", validPayload()); + + const compressCalls = sdk.triggered.filter((t) => t.id === "mem::compress"); + expect(compressCalls).toHaveLength(0); + }); +}); + +describe("buildSyntheticCompression", () => { + it("maps common tool names to the right ObservationType", async () => { + const { buildSyntheticCompression } = await import( + "../src/functions/compress-synthetic.js" + ); + const base: RawObservation = { + id: "obs_1", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + hookType: "post_tool_use", + raw: {}, + }; + const cases: Array<[string, string]> = [ + ["Read", "file_read"], + ["Write", "file_write"], + ["Edit", "file_edit"], + ["Bash", "command_run"], + ["Grep", "search"], + ["WebFetch", "web_fetch"], + ["Task", "subagent"], + ["UnknownTool", "other"], + ]; + for (const [name, expectedType] of cases) { + const synthetic = ( + await import("../src/functions/compress-synthetic.js") + ).buildSyntheticCompression({ ...base, toolName: name }); + expect(synthetic.type, `${name} -> ${expectedType}`).toBe(expectedType); + } + // silence unused warning — buildSyntheticCompression is used above + expect(typeof buildSyntheticCompression).toBe("function"); + }); + + it("extracts file paths from tool_input into the files array", async () => { + const { buildSyntheticCompression } = await import( + "../src/functions/compress-synthetic.js" + ); + const synth = buildSyntheticCompression({ + id: "obs_2", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + hookType: "post_tool_use", + toolName: "Edit", + toolInput: { file_path: "/app/src/bar.ts", pattern: "foo" }, + raw: {}, + }); + expect(synth.files).toContain("/app/src/bar.ts"); + expect(synth.files).toContain("foo"); + expect(synth.type).toBe("file_edit"); + }); + + it("truncates long narratives so it can't blow up the index", async () => { + const { buildSyntheticCompression } = await import( + "../src/functions/compress-synthetic.js" + ); + const longInput = "x".repeat(2000); + const synth = buildSyntheticCompression({ + id: "obs_3", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + hookType: "post_tool_use", + toolName: "Bash", + toolInput: { command: longInput }, + toolOutput: longInput, + raw: {}, + }); + expect(synth.narrative.length).toBeLessThanOrEqual(400); + }); + + it("maps post_tool_failure to the error type even with no tool name", async () => { + const { buildSyntheticCompression } = await import( + "../src/functions/compress-synthetic.js" + ); + const synth = buildSyntheticCompression({ + id: "obs_4", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + hookType: "post_tool_failure", + raw: {}, + }); + expect(synth.type).toBe("error"); + }); +}); diff --git a/test/auto-forget.test.ts b/test/auto-forget.test.ts new file mode 100644 index 0000000..0f104eb --- /dev/null +++ b/test/auto-forget.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerAutoForgetFunction } from "../src/functions/auto-forget.js"; +import { + getSearchIndex, + setIndexPersistence, +} from "../src/functions/search.js"; +import { memoryToObservation } from "../src/state/memory-utils.js"; +import type { Memory, CompressedObservation, Session } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeMemory(overrides: Partial = {}): Memory { + return { + id: "mem_1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "pattern", + title: "Test memory", + content: "This is a test memory with enough words for comparison", + concepts: ["test"], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + ...overrides, + }; +} + +describe("Auto-Forget Function", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerAutoForgetFunction(sdk as never, kv as never); + }); + + it("detects and deletes TTL-expired memories", async () => { + const expired = makeMemory({ + id: "mem_expired", + forgetAfter: "2020-01-01T00:00:00Z", + }); + await kv.set("mem:memories", "mem_expired", expired); + + const result = (await sdk.trigger("mem::auto-forget", {})) as { + ttlExpired: string[]; + }; + + expect(result.ttlExpired).toContain("mem_expired"); + const deleted = await kv.get("mem:memories", "mem_expired"); + expect(deleted).toBeNull(); + }); + + it("detects contradiction between very similar memories", async () => { + const mem1 = makeMemory({ + id: "mem_1", + content: "Use React hooks for state management in all components", + createdAt: "2026-01-01T00:00:00Z", + }); + const mem2 = makeMemory({ + id: "mem_2", + content: "Use React hooks for state management in all components", + createdAt: "2026-02-01T00:00:00Z", + }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + + const result = (await sdk.trigger("mem::auto-forget", {})) as { + contradictions: Array<{ + memoryA: string; + memoryB: string; + similarity: number; + }>; + }; + + expect(result.contradictions.length).toBe(1); + const older = await kv.get("mem:memories", "mem_1"); + expect(older!.isLatest).toBe(false); + }); + + it("evicts low-value old observations", async () => { + const session: Session = { + id: "ses_1", + project: "my-project", + cwd: "/tmp", + startedAt: "2025-01-01T00:00:00Z", + status: "completed", + observationCount: 1, + }; + await kv.set("mem:sessions", "ses_1", session); + + const oldLowObs: CompressedObservation = { + id: "obs_old", + sessionId: "ses_1", + timestamp: "2025-01-01T00:00:00Z", + type: "other", + title: "trivial event", + facts: [], + narrative: "nothing important", + concepts: [], + files: [], + importance: 1, + }; + await kv.set("mem:obs:ses_1", "obs_old", oldLowObs); + + const result = (await sdk.trigger("mem::auto-forget", {})) as { + lowValueObs: string[]; + }; + + expect(result.lowValueObs).toContain("obs_old"); + }); + + it("dryRun mode identifies but does not delete anything", async () => { + const expired = makeMemory({ + id: "mem_expired", + forgetAfter: "2020-01-01T00:00:00Z", + }); + await kv.set("mem:memories", "mem_expired", expired); + + const result = (await sdk.trigger("mem::auto-forget", { dryRun: true })) as { + ttlExpired: string[]; + dryRun: boolean; + }; + + expect(result.dryRun).toBe(true); + expect(result.ttlExpired).toContain("mem_expired"); + + const stillExists = await kv.get("mem:memories", "mem_expired"); + expect(stillExists).not.toBeNull(); + }); + + describe("search-index cleanup", () => { + beforeEach(() => { + getSearchIndex().clear(); + setIndexPersistence(null); + }); + + afterEach(() => { + setIndexPersistence(null); + }); + + it("removes TTL-expired memories from the BM25 index and flushes persistence", async () => { + const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) }; + setIndexPersistence(persistence); + + const expired = makeMemory({ + id: "mem_expired", + forgetAfter: "2020-01-01T00:00:00Z", + }); + await kv.set("mem:memories", "mem_expired", expired); + getSearchIndex().add(memoryToObservation(expired)); + expect(getSearchIndex().has("mem_expired")).toBe(true); + + await sdk.trigger("mem::auto-forget", {}); + + expect(getSearchIndex().has("mem_expired")).toBe(false); + expect(persistence.save).toHaveBeenCalled(); + }); + + it("removes evicted low-value observations from the BM25 index", async () => { + const session: Session = { + id: "ses_1", + project: "my-project", + cwd: "/tmp", + startedAt: "2025-01-01T00:00:00Z", + status: "completed", + observationCount: 1, + }; + await kv.set("mem:sessions", "ses_1", session); + + const oldLowObs: CompressedObservation = { + id: "obs_old", + sessionId: "ses_1", + timestamp: "2025-01-01T00:00:00Z", + type: "other", + title: "trivial event", + facts: [], + narrative: "nothing important", + concepts: [], + files: [], + importance: 1, + }; + await kv.set("mem:obs:ses_1", "obs_old", oldLowObs); + getSearchIndex().add(oldLowObs); + expect(getSearchIndex().has("obs_old")).toBe(true); + + await sdk.trigger("mem::auto-forget", {}); + + expect(getSearchIndex().has("obs_old")).toBe(false); + }); + + it("does not flush persistence on dryRun", async () => { + const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) }; + setIndexPersistence(persistence); + + const expired = makeMemory({ + id: "mem_expired", + forgetAfter: "2020-01-01T00:00:00Z", + }); + await kv.set("mem:memories", "mem_expired", expired); + getSearchIndex().add(memoryToObservation(expired)); + + await sdk.trigger("mem::auto-forget", { dryRun: true }); + + // dryRun must not mutate the index or write to disk. + expect(getSearchIndex().has("mem_expired")).toBe(true); + expect(persistence.save).not.toHaveBeenCalled(); + }); + }); + + it("does not flag non-similar memories as contradictions", async () => { + const mem1 = makeMemory({ + id: "mem_1", + content: "We use TypeScript with strict mode enabled for all backend services", + }); + const mem2 = makeMemory({ + id: "mem_2", + content: "The deployment pipeline runs integration tests before merging to main", + }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + + const result = (await sdk.trigger("mem::auto-forget", {})) as { + contradictions: unknown[]; + }; + + expect(result.contradictions.length).toBe(0); + }); +}); diff --git a/test/cascade.test.ts b/test/cascade.test.ts new file mode 100644 index 0000000..8462abc --- /dev/null +++ b/test/cascade.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerCascadeFunction } from "../src/functions/cascade.js"; +import type { Memory, GraphNode, GraphEdge } from "../src/types.js"; +import { mockKV, mockSdk } from "./helpers/mocks.js"; + +describe("Cascade Update Function", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + vi.clearAllMocks(); + registerCascadeFunction(sdk as never, kv as never); + }); + + it("returns error when supersededMemoryId is missing", async () => { + const result = (await sdk.trigger("mem::cascade-update", {})) as { + success: boolean; + error: string; + }; + expect(result.success).toBe(false); + expect(result.error).toBe("supersededMemoryId is required"); + }); + + it("returns error for non-existent memory", async () => { + const result = (await sdk.trigger("mem::cascade-update", { + supersededMemoryId: "mem_missing", + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toBe("superseded memory not found"); + }); + + it("flags graph nodes referencing superseded observation IDs", async () => { + const memory: Memory = { + id: "mem_old", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "fact", + title: "Old fact", + content: "Old content", + concepts: ["react"], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: false, + sourceObservationIds: ["obs_a", "obs_b"], + }; + await kv.set("mem:memories", "mem_old", memory); + + const node: GraphNode = { + id: "node_1", + type: "concept", + name: "react", + properties: {}, + sourceObservationIds: ["obs_a"], + createdAt: "2026-03-01T00:00:00Z", + }; + await kv.set("mem:graph:nodes", "node_1", node); + + const unrelatedNode: GraphNode = { + id: "node_2", + type: "file", + name: "index.ts", + properties: {}, + sourceObservationIds: ["obs_c"], + createdAt: "2026-03-01T00:00:00Z", + }; + await kv.set("mem:graph:nodes", "node_2", unrelatedNode); + + const result = (await sdk.trigger("mem::cascade-update", { + supersededMemoryId: "mem_old", + })) as { success: boolean; flagged: { nodes: number; edges: number } }; + + expect(result.success).toBe(true); + expect(result.flagged.nodes).toBe(1); + + const updated = await kv.get("mem:graph:nodes", "node_1"); + expect(updated!.stale).toBe(true); + + const unchanged = await kv.get("mem:graph:nodes", "node_2"); + expect(unchanged!.stale).toBeUndefined(); + }); + + it("flags graph edges referencing superseded observation IDs", async () => { + const memory: Memory = { + id: "mem_old2", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "pattern", + title: "Old pattern", + content: "Old pattern content", + concepts: ["testing"], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: false, + sourceObservationIds: ["obs_x"], + }; + await kv.set("mem:memories", "mem_old2", memory); + + const edge: GraphEdge = { + id: "edge_1", + type: "uses", + sourceNodeId: "node_a", + targetNodeId: "node_b", + weight: 1, + sourceObservationIds: ["obs_x", "obs_y"], + createdAt: "2026-03-01T00:00:00Z", + }; + await kv.set("mem:graph:edges", "edge_1", edge); + + const result = (await sdk.trigger("mem::cascade-update", { + supersededMemoryId: "mem_old2", + })) as { success: boolean; flagged: { edges: number } }; + + expect(result.success).toBe(true); + expect(result.flagged.edges).toBe(1); + + const updated = await kv.get("mem:graph:edges", "edge_1"); + expect(updated!.stale).toBe(true); + }); + + it("counts sibling memories sharing 2+ concepts", async () => { + const superseded: Memory = { + id: "mem_superseded", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "architecture", + title: "React architecture", + content: "Old arch", + concepts: ["react", "frontend", "typescript"], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: false, + }; + await kv.set("mem:memories", "mem_superseded", superseded); + + const sibling: Memory = { + id: "mem_sibling", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "pattern", + title: "React patterns", + content: "Sibling memory sharing concepts", + concepts: ["react", "typescript"], + files: [], + sessionIds: [], + strength: 6, + version: 1, + isLatest: true, + }; + await kv.set("mem:memories", "mem_sibling", sibling); + + const unrelated: Memory = { + id: "mem_unrelated", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "fact", + title: "Python setup", + content: "Unrelated memory", + concepts: ["python", "backend"], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: true, + }; + await kv.set("mem:memories", "mem_unrelated", unrelated); + + const result = (await sdk.trigger("mem::cascade-update", { + supersededMemoryId: "mem_superseded", + })) as { success: boolean; flagged: { siblingMemories: number }; total: number }; + + expect(result.success).toBe(true); + expect(result.flagged.siblingMemories).toBe(1); + expect(result.total).toBeGreaterThanOrEqual(1); + }); + + it("skips already stale nodes", async () => { + const memory: Memory = { + id: "mem_skip", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "fact", + title: "Skip test", + content: "Content", + concepts: [], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: false, + sourceObservationIds: ["obs_s"], + }; + await kv.set("mem:memories", "mem_skip", memory); + + const node: GraphNode = { + id: "node_stale", + type: "concept", + name: "already stale", + properties: {}, + sourceObservationIds: ["obs_s"], + createdAt: "2026-03-01T00:00:00Z", + stale: true, + }; + await kv.set("mem:graph:nodes", "node_stale", node); + + const result = (await sdk.trigger("mem::cascade-update", { + supersededMemoryId: "mem_skip", + })) as { success: boolean; flagged: { nodes: number } }; + + expect(result.success).toBe(true); + expect(result.flagged.nodes).toBe(0); + }); + + it("does not flag siblings when fewer than 2 shared concepts", async () => { + const memory: Memory = { + id: "mem_one_concept", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "fact", + title: "One concept", + content: "Content", + concepts: ["react"], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: false, + }; + await kv.set("mem:memories", "mem_one_concept", memory); + + const result = (await sdk.trigger("mem::cascade-update", { + supersededMemoryId: "mem_one_concept", + })) as { success: boolean; flagged: { siblingMemories: number } }; + + expect(result.success).toBe(true); + expect(result.flagged.siblingMemories).toBe(0); + }); + + it("returns zero counts when no sourceObservationIds and < 2 concepts", async () => { + const memory: Memory = { + id: "mem_empty", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "fact", + title: "Empty refs", + content: "No references", + concepts: [], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: false, + }; + await kv.set("mem:memories", "mem_empty", memory); + + const result = (await sdk.trigger("mem::cascade-update", { + supersededMemoryId: "mem_empty", + })) as { success: boolean; total: number }; + + expect(result.success).toBe(true); + expect(result.total).toBe(0); + }); +}); diff --git a/test/checkpoints.test.ts b/test/checkpoints.test.ts new file mode 100644 index 0000000..4c6af90 --- /dev/null +++ b/test/checkpoints.test.ts @@ -0,0 +1,494 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerCheckpointsFunction } from "../src/functions/checkpoints.js"; +import type { Action, ActionEdge, Checkpoint } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeAction( + id: string, + status: Action["status"] = "blocked", +): Action { + return { + id, + title: `Action ${id}`, + description: `Description for ${id}`, + status, + priority: 5, + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + createdBy: "agent-setup", + tags: [], + sourceObservationIds: [], + sourceMemoryIds: [], + }; +} + +describe("Checkpoint Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerCheckpointsFunction(sdk as never, kv as never); + }); + + describe("mem::checkpoint-create", () => { + it("creates a checkpoint with valid name", async () => { + const result = (await sdk.trigger("mem::checkpoint-create", { + name: "CI Build", + description: "Wait for CI to pass", + type: "ci", + })) as { success: boolean; checkpoint: Checkpoint }; + + expect(result.success).toBe(true); + expect(result.checkpoint.name).toBe("CI Build"); + expect(result.checkpoint.description).toBe("Wait for CI to pass"); + expect(result.checkpoint.status).toBe("pending"); + expect(result.checkpoint.type).toBe("ci"); + expect(result.checkpoint.id).toMatch(/^ckpt_/); + }); + + it("returns error when name is missing", async () => { + const result = (await sdk.trigger("mem::checkpoint-create", { + name: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("name is required"); + }); + + it("defaults type to external when not specified", async () => { + const result = (await sdk.trigger("mem::checkpoint-create", { + name: "External Gate", + })) as { success: boolean; checkpoint: Checkpoint }; + + expect(result.success).toBe(true); + expect(result.checkpoint.type).toBe("external"); + }); + + it("sets expiresAt when expiresInMs is provided", async () => { + const before = Date.now(); + const result = (await sdk.trigger("mem::checkpoint-create", { + name: "Timed Gate", + expiresInMs: 60000, + })) as { success: boolean; checkpoint: Checkpoint }; + + expect(result.success).toBe(true); + expect(result.checkpoint.expiresAt).toBeDefined(); + const expiresAt = new Date(result.checkpoint.expiresAt!).getTime(); + expect(expiresAt).toBeGreaterThanOrEqual(before + 60000); + }); + + it("creates action edges for linkedActionIds", async () => { + await kv.set("mem:actions", "act_1", makeAction("act_1")); + await kv.set("mem:actions", "act_2", makeAction("act_2")); + + const result = (await sdk.trigger("mem::checkpoint-create", { + name: "Deployment Gate", + type: "deploy", + linkedActionIds: ["act_1", "act_2"], + })) as { success: boolean; checkpoint: Checkpoint }; + + expect(result.success).toBe(true); + expect(result.checkpoint.linkedActionIds).toEqual(["act_1", "act_2"]); + + const edges = await kv.list("mem:action-edges"); + expect(edges.length).toBe(2); + expect(edges[0].type).toBe("gated_by"); + expect(edges[0].targetActionId).toBe(result.checkpoint.id); + expect(edges.map((e) => e.sourceActionId).sort()).toEqual(["act_1", "act_2"]); + }); + + it("creates no edges when linkedActionIds is empty", async () => { + await sdk.trigger("mem::checkpoint-create", { + name: "No Links", + linkedActionIds: [], + }); + + const edges = await kv.list("mem:action-edges"); + expect(edges.length).toBe(0); + }); + }); + + describe("mem::checkpoint-resolve", () => { + it("resolves a pending checkpoint to passed", async () => { + const created = (await sdk.trigger("mem::checkpoint-create", { + name: "CI Gate", + type: "ci", + })) as { success: boolean; checkpoint: Checkpoint }; + + const result = (await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: created.checkpoint.id, + status: "passed", + resolvedBy: "ci-bot", + result: { buildId: 123 }, + })) as { success: boolean; checkpoint: Checkpoint; unblockedCount: number }; + + expect(result.success).toBe(true); + expect(result.checkpoint.status).toBe("passed"); + expect(result.checkpoint.resolvedBy).toBe("ci-bot"); + expect(result.checkpoint.resolvedAt).toBeDefined(); + expect(result.checkpoint.result).toEqual({ buildId: 123 }); + }); + + it("resolves a pending checkpoint to failed", async () => { + const created = (await sdk.trigger("mem::checkpoint-create", { + name: "Approval Gate", + type: "approval", + })) as { success: boolean; checkpoint: Checkpoint }; + + const result = (await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: created.checkpoint.id, + status: "failed", + resolvedBy: "reviewer", + })) as { success: boolean; checkpoint: Checkpoint }; + + expect(result.success).toBe(true); + expect(result.checkpoint.status).toBe("failed"); + }); + + it("returns error when checkpoint is already resolved", async () => { + const created = (await sdk.trigger("mem::checkpoint-create", { + name: "Already Done", + })) as { success: boolean; checkpoint: Checkpoint }; + + await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: created.checkpoint.id, + status: "passed", + }); + + const result = (await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: created.checkpoint.id, + status: "failed", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("checkpoint already passed"); + }); + + it("returns error for nonexistent checkpoint", async () => { + const result = (await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: "ckpt_nonexistent", + status: "passed", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("checkpoint not found"); + }); + + it("returns error when checkpointId or status is missing", async () => { + const result = (await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: "", + status: "passed", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("checkpointId and status are required"); + }); + + it("unblocks gated actions when all checkpoints pass", async () => { + await kv.set("mem:actions", "act_1", makeAction("act_1", "blocked")); + + const cp1 = (await sdk.trigger("mem::checkpoint-create", { + name: "Gate 1", + type: "ci", + linkedActionIds: ["act_1"], + })) as { success: boolean; checkpoint: Checkpoint }; + + const cp2 = (await sdk.trigger("mem::checkpoint-create", { + name: "Gate 2", + type: "approval", + linkedActionIds: ["act_1"], + })) as { success: boolean; checkpoint: Checkpoint }; + + await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: cp1.checkpoint.id, + status: "passed", + }); + + const actionAfterFirst = await kv.get("mem:actions", "act_1"); + expect(actionAfterFirst!.status).toBe("blocked"); + + const result = (await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: cp2.checkpoint.id, + status: "passed", + })) as { success: boolean; unblockedCount: number }; + + expect(result.success).toBe(true); + expect(result.unblockedCount).toBe(1); + + const action = await kv.get("mem:actions", "act_1"); + expect(action!.status).toBe("pending"); + }); + + it("does not unblock actions when checkpoint fails", async () => { + await kv.set("mem:actions", "act_1", makeAction("act_1", "blocked")); + + const cp = (await sdk.trigger("mem::checkpoint-create", { + name: "Failing Gate", + linkedActionIds: ["act_1"], + })) as { success: boolean; checkpoint: Checkpoint }; + + const result = (await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: cp.checkpoint.id, + status: "failed", + })) as { success: boolean; unblockedCount: number }; + + expect(result.success).toBe(true); + expect(result.unblockedCount).toBe(0); + + const action = await kv.get("mem:actions", "act_1"); + expect(action!.status).toBe("blocked"); + }); + + it("does not unblock actions that are not in blocked status", async () => { + await kv.set("mem:actions", "act_1", makeAction("act_1", "active")); + + const cp = (await sdk.trigger("mem::checkpoint-create", { + name: "Gate for non-blocked", + linkedActionIds: ["act_1"], + })) as { success: boolean; checkpoint: Checkpoint }; + + const result = (await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: cp.checkpoint.id, + status: "passed", + })) as { success: boolean; unblockedCount: number }; + + expect(result.success).toBe(true); + expect(result.unblockedCount).toBe(0); + }); + }); + + describe("mem::checkpoint-list", () => { + beforeEach(async () => { + await sdk.trigger("mem::checkpoint-create", { + name: "CI Check", + type: "ci", + }); + await sdk.trigger("mem::checkpoint-create", { + name: "Approval Check", + type: "approval", + }); + await sdk.trigger("mem::checkpoint-create", { + name: "Deploy Check", + type: "deploy", + }); + }); + + it("lists all checkpoints when no filters applied", async () => { + const result = (await sdk.trigger("mem::checkpoint-list", {})) as { + success: boolean; + checkpoints: Checkpoint[]; + }; + + expect(result.success).toBe(true); + expect(result.checkpoints.length).toBe(3); + }); + + it("filters checkpoints by status", async () => { + const all = (await sdk.trigger("mem::checkpoint-list", {})) as { + checkpoints: Checkpoint[]; + }; + const firstId = all.checkpoints[0].id; + + await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: firstId, + status: "passed", + }); + + const pending = (await sdk.trigger("mem::checkpoint-list", { + status: "pending", + })) as { success: boolean; checkpoints: Checkpoint[] }; + + expect(pending.success).toBe(true); + expect(pending.checkpoints.length).toBe(2); + expect(pending.checkpoints.every((c) => c.status === "pending")).toBe(true); + + const passed = (await sdk.trigger("mem::checkpoint-list", { + status: "passed", + })) as { success: boolean; checkpoints: Checkpoint[] }; + + expect(passed.checkpoints.length).toBe(1); + expect(passed.checkpoints[0].status).toBe("passed"); + }); + + it("filters checkpoints by type", async () => { + const result = (await sdk.trigger("mem::checkpoint-list", { + type: "ci", + })) as { success: boolean; checkpoints: Checkpoint[] }; + + expect(result.success).toBe(true); + expect(result.checkpoints.length).toBe(1); + expect(result.checkpoints[0].type).toBe("ci"); + expect(result.checkpoints[0].name).toBe("CI Check"); + }); + + it("returns empty list when no checkpoints match filter", async () => { + const result = (await sdk.trigger("mem::checkpoint-list", { + type: "external", + })) as { success: boolean; checkpoints: Checkpoint[] }; + + expect(result.success).toBe(true); + expect(result.checkpoints.length).toBe(0); + }); + + it("sorts checkpoints by createdAt descending", async () => { + const result = (await sdk.trigger("mem::checkpoint-list", {})) as { + success: boolean; + checkpoints: Checkpoint[]; + }; + + for (let i = 0; i < result.checkpoints.length - 1; i++) { + const current = new Date(result.checkpoints[i].createdAt).getTime(); + const next = new Date(result.checkpoints[i + 1].createdAt).getTime(); + expect(current).toBeGreaterThanOrEqual(next); + } + }); + }); + + describe("mem::checkpoint-expire", () => { + it("expires pending checkpoints past their expiresAt", async () => { + const created = (await sdk.trigger("mem::checkpoint-create", { + name: "Expiring Gate", + expiresInMs: 1, + })) as { success: boolean; checkpoint: Checkpoint }; + + created.checkpoint.expiresAt = new Date(Date.now() - 60000).toISOString(); + await kv.set("mem:checkpoints", created.checkpoint.id, created.checkpoint); + + const result = (await sdk.trigger("mem::checkpoint-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(1); + + const cp = await kv.get("mem:checkpoints", created.checkpoint.id); + expect(cp!.status).toBe("expired"); + expect(cp!.resolvedAt).toBeDefined(); + }); + + it("does not expire non-pending checkpoints", async () => { + const created = (await sdk.trigger("mem::checkpoint-create", { + name: "Already Passed", + expiresInMs: 1, + })) as { success: boolean; checkpoint: Checkpoint }; + + await sdk.trigger("mem::checkpoint-resolve", { + checkpointId: created.checkpoint.id, + status: "passed", + }); + + const cp = await kv.get("mem:checkpoints", created.checkpoint.id); + cp!.expiresAt = new Date(Date.now() - 60000).toISOString(); + await kv.set("mem:checkpoints", created.checkpoint.id, cp); + + const result = (await sdk.trigger("mem::checkpoint-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(0); + }); + + it("does not expire checkpoints without expiresAt", async () => { + await sdk.trigger("mem::checkpoint-create", { + name: "No Expiry", + }); + + const result = (await sdk.trigger("mem::checkpoint-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(0); + }); + + it("does not expire checkpoints whose expiresAt is in the future", async () => { + await sdk.trigger("mem::checkpoint-create", { + name: "Future Gate", + expiresInMs: 3600000, + }); + + const result = (await sdk.trigger("mem::checkpoint-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(0); + }); + + it("handles multiple expired checkpoints", async () => { + const cp1 = (await sdk.trigger("mem::checkpoint-create", { + name: "Expired 1", + expiresInMs: 1, + })) as { success: boolean; checkpoint: Checkpoint }; + + const cp2 = (await sdk.trigger("mem::checkpoint-create", { + name: "Expired 2", + expiresInMs: 1, + })) as { success: boolean; checkpoint: Checkpoint }; + + cp1.checkpoint.expiresAt = new Date(Date.now() - 60000).toISOString(); + await kv.set("mem:checkpoints", cp1.checkpoint.id, cp1.checkpoint); + + cp2.checkpoint.expiresAt = new Date(Date.now() - 30000).toISOString(); + await kv.set("mem:checkpoints", cp2.checkpoint.id, cp2.checkpoint); + + const result = (await sdk.trigger("mem::checkpoint-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(2); + }); + }); +}); diff --git a/test/circuit-breaker.test.ts b/test/circuit-breaker.test.ts new file mode 100644 index 0000000..a4e32a3 --- /dev/null +++ b/test/circuit-breaker.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { CircuitBreaker } from "../src/providers/circuit-breaker.js"; + +describe("CircuitBreaker", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts in closed state", () => { + const cb = new CircuitBreaker(); + expect(cb.getState().state).toBe("closed"); + expect(cb.isAllowed).toBe(true); + }); + + it("stays closed after fewer than 3 failures", () => { + const cb = new CircuitBreaker(); + cb.recordFailure(); + cb.recordFailure(); + expect(cb.getState().state).toBe("closed"); + expect(cb.isAllowed).toBe(true); + }); + + it("opens after 3 failures within the window", () => { + const cb = new CircuitBreaker(); + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + expect(cb.getState().state).toBe("open"); + expect(cb.isAllowed).toBe(false); + }); + + it("resets failure count when failures are outside the window", () => { + const cb = new CircuitBreaker(); + cb.recordFailure(); + cb.recordFailure(); + vi.advanceTimersByTime(61_000); + cb.recordFailure(); + expect(cb.getState().state).toBe("closed"); + expect(cb.getState().failures).toBe(1); + }); + + it("transitions to half-open after recovery timeout", () => { + const cb = new CircuitBreaker(); + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + expect(cb.isAllowed).toBe(false); + vi.advanceTimersByTime(30_000); + expect(cb.isAllowed).toBe(true); + expect(cb.getState().state).toBe("half-open"); + }); + + it("closes on success in half-open state", () => { + const cb = new CircuitBreaker(); + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + vi.advanceTimersByTime(30_000); + cb.isAllowed; + cb.recordSuccess(); + expect(cb.getState().state).toBe("closed"); + expect(cb.getState().failures).toBe(0); + expect(cb.getState().lastFailureAt).toBeNull(); + }); + + it("reopens on failure in half-open state", () => { + const cb = new CircuitBreaker(); + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + vi.advanceTimersByTime(30_000); + cb.isAllowed; + cb.recordFailure(); + expect(cb.getState().state).toBe("open"); + }); + + it("records lastFailureAt timestamp", () => { + const cb = new CircuitBreaker(); + vi.setSystemTime(new Date("2026-01-15T10:00:00Z")); + cb.recordFailure(); + expect(cb.getState().lastFailureAt).toBe( + new Date("2026-01-15T10:00:00Z").getTime(), + ); + }); + + it("records openedAt timestamp", () => { + const cb = new CircuitBreaker(); + vi.setSystemTime(new Date("2026-01-15T10:00:00Z")); + cb.recordFailure(); + cb.recordFailure(); + cb.recordFailure(); + expect(cb.getState().openedAt).toBe( + new Date("2026-01-15T10:00:00Z").getTime(), + ); + }); + + it("success in closed state is a no-op", () => { + const cb = new CircuitBreaker(); + cb.recordSuccess(); + expect(cb.getState().state).toBe("closed"); + expect(cb.getState().failures).toBe(0); + }); +}); diff --git a/test/claude-bridge-path.test.ts b/test/claude-bridge-path.test.ts new file mode 100644 index 0000000..fa3573d --- /dev/null +++ b/test/claude-bridge-path.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { loadClaudeBridgeConfig } from "../src/config.js"; + +// bridge path must match Claude Code's slug convention exactly: +// ~/.claude/projects//MEMORY.md +// where replaces every / and \ with - and KEEPS any leading -. +// The previous code stripped the leading - and added a /memory/ +// subdirectory; the bridge then wrote a file Claude Code never read. +describe("loadClaudeBridgeConfig path (#625)", () => { + const ORIG_ENV = { ...process.env }; + beforeEach(() => { + delete process.env["CLAUDE_MEMORY_BRIDGE"]; + delete process.env["CLAUDE_PROJECT_PATH"]; + delete process.env["CLAUDE_MEMORY_LINE_BUDGET"]; + }); + afterEach(() => { + process.env = { ...ORIG_ENV }; + }); + + it("preserves leading - on POSIX absolute paths", () => { + process.env["CLAUDE_MEMORY_BRIDGE"] = "true"; + process.env["CLAUDE_PROJECT_PATH"] = "/home/user/repos/my-project"; + const cfg = loadClaudeBridgeConfig(); + expect(cfg.memoryFilePath).toBe( + join(homedir(), ".claude", "projects", "-home-user-repos-my-project", "MEMORY.md"), + ); + }); + + it("writes MEMORY.md directly under the slug dir, no memory/ subdir", () => { + process.env["CLAUDE_MEMORY_BRIDGE"] = "true"; + process.env["CLAUDE_PROJECT_PATH"] = "/Users/x/agentmemory"; + const cfg = loadClaudeBridgeConfig(); + expect(cfg.memoryFilePath).not.toMatch(/[/\\]memory[/\\]MEMORY\.md$/); + expect(cfg.memoryFilePath).toMatch(/-Users-x-agentmemory[/\\]MEMORY\.md$/); + }); + + it("returns empty memoryFilePath when bridge disabled", () => { + const cfg = loadClaudeBridgeConfig(); + expect(cfg.enabled).toBe(false); + expect(cfg.memoryFilePath).toBe(""); + }); + + it("returns empty memoryFilePath when project path unset", () => { + process.env["CLAUDE_MEMORY_BRIDGE"] = "true"; + const cfg = loadClaudeBridgeConfig(); + expect(cfg.enabled).toBe(true); + expect(cfg.memoryFilePath).toBe(""); + }); + + it("handles Windows-style backslash paths by swapping to -", () => { + process.env["CLAUDE_MEMORY_BRIDGE"] = "true"; + process.env["CLAUDE_PROJECT_PATH"] = "C:\\Users\\x\\project"; + const cfg = loadClaudeBridgeConfig(); + expect(cfg.memoryFilePath).toMatch(/C:-Users-x-project[/\\]MEMORY\.md$/); + }); +}); diff --git a/test/claude-bridge.test.ts b/test/claude-bridge.test.ts new file mode 100644 index 0000000..42f2b02 --- /dev/null +++ b/test/claude-bridge.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("node:path", async () => ({ + ...(await vi.importActual("node:path")), + dirname: vi.fn().mockReturnValue("/tmp"), +})); + +import { registerClaudeBridgeFunction } from "../src/functions/claude-bridge.js"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import type { ClaudeBridgeConfig, Memory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +const enabledConfig: ClaudeBridgeConfig = { + enabled: true, + projectPath: "/tmp/my-project", + memoryFilePath: "/tmp/.claude/MEMORY.md", + lineBudget: 200, +}; + +const disabledConfig: ClaudeBridgeConfig = { + enabled: false, + projectPath: "", + memoryFilePath: "", + lineBudget: 200, +}; + +describe("Claude Bridge Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + vi.clearAllMocks(); + }); + + it("claude-bridge-read returns content when file exists", async () => { + registerClaudeBridgeFunction(sdk as never, kv as never, enabledConfig); + + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + "# Memory\n\n## Project Summary\nA test project\n\n## Key Memories\nSome memories", + ); + + const result = (await sdk.trigger("mem::claude-bridge-read", {})) as { + success: boolean; + content: string; + sections: Record; + }; + + expect(result.success).toBe(true); + expect(result.content).toContain("# Memory"); + expect(result.sections).toBeDefined(); + expect(result.sections["Project Summary"]).toBe("A test project"); + }); + + it("claude-bridge-read returns empty when file does not exist", async () => { + registerClaudeBridgeFunction(sdk as never, kv as never, enabledConfig); + + vi.mocked(existsSync).mockReturnValue(false); + + const result = (await sdk.trigger("mem::claude-bridge-read", {})) as { + success: boolean; + content: string; + parsed: boolean; + }; + + expect(result.success).toBe(true); + expect(result.content).toBe(""); + expect(result.parsed).toBe(false); + }); + + it("claude-bridge-sync writes MEMORY.md with memories", async () => { + registerClaudeBridgeFunction(sdk as never, kv as never, enabledConfig); + + const mem: Memory = { + id: "mem_1", + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type: "pattern", + title: "Auth pattern", + content: "Always validate tokens", + concepts: ["auth"], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + }; + await kv.set("mem:memories", "mem_1", mem); + + vi.mocked(existsSync).mockReturnValue(true); + + const result = (await sdk.trigger("mem::claude-bridge-sync", {})) as { + success: boolean; + path: string; + lines: number; + }; + + expect(result.success).toBe(true); + expect(result.path).toBe("/tmp/.claude/MEMORY.md"); + expect(writeFileSync).toHaveBeenCalled(); + const writtenContent = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(writtenContent).toContain("Auth pattern"); + }); + + it("claude-bridge-sync returns error when not configured", async () => { + registerClaudeBridgeFunction(sdk as never, kv as never, disabledConfig); + + const result = (await sdk.trigger("mem::claude-bridge-sync", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("not configured"); + }); + + it("claude-bridge-read returns error when not configured", async () => { + registerClaudeBridgeFunction(sdk as never, kv as never, disabledConfig); + + const result = (await sdk.trigger("mem::claude-bridge-read", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("not configured"); + }); +}); diff --git a/test/claude-code-with-hooks.test.ts b/test/claude-code-with-hooks.test.ts new file mode 100644 index 0000000..6c36b09 --- /dev/null +++ b/test/claude-code-with-hooks.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "node:path"; +import { + buildMergedHooks, + findPluginRoot, + type HookManifest, +} from "../src/cli/connect/codex-hooks.js"; + +const PLUGIN_ROOT = resolve(__dirname, "..", "plugin"); + +describe("buildMergedHooks against plugin/hooks/hooks.json (Claude Code)", () => { + it("locates the same plugin root used by the codex variant", () => { + expect(findPluginRoot()).toBe(PLUGIN_ROOT); + }); + + it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => { + const merged = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json"); + for (const entries of Object.values(merged.hooks)) { + for (const entry of entries) { + for (const handler of entry.hooks) { + expect(handler.command).not.toContain("${CLAUDE_PLUGIN_ROOT}"); + expect(handler.command).toContain(`${PLUGIN_ROOT}/scripts/`); + } + } + } + }); + + it("includes Claude-only events that hooks.codex.json omits", () => { + const merged = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json"); + const events = Object.keys(merged.hooks); + expect(events).toContain("SessionStart"); + expect(events).toContain("Stop"); + const claudeOnly = ["SessionEnd", "SubagentStop", "Notification"]; + expect( + claudeOnly.some((e) => events.includes(e)), + `hooks.json should include at least one Claude-only event (${claudeOnly.join(", ")})`, + ).toBe(true); + }); + + it("appends to existing user hooks without dropping them", () => { + const existing: HookManifest = { + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "echo user-custom-claude" }] }, + ], + }, + }; + const merged = buildMergedHooks(existing, PLUGIN_ROOT, "hooks.json"); + const sessionStart = merged.hooks["SessionStart"]!; + expect( + sessionStart.some((e) => + e.hooks.some((h) => h.command === "echo user-custom-claude"), + ), + ).toBe(true); + expect( + sessionStart.some((e) => + e.hooks.some((h) => + h.command.includes(`${PLUGIN_ROOT}/scripts/session-start.mjs`), + ), + ), + ).toBe(true); + }); + + it("re-install strips previous agentmemory entries (idempotent)", () => { + const first = buildMergedHooks(null, PLUGIN_ROOT, "hooks.json"); + const second = buildMergedHooks(first, PLUGIN_ROOT, "hooks.json"); + for (const event of Object.keys(first.hooks)) { + expect( + second.hooks[event]!.length, + `${event} should not double after second install`, + ).toBe(first.hooks[event]!.length); + } + }); +}); diff --git a/test/cli-connect.test.ts b/test/cli-connect.test.ts new file mode 100644 index 0000000..46a1f24 --- /dev/null +++ b/test/cli-connect.test.ts @@ -0,0 +1,503 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + ADAPTERS, + knownAgents, + resolveAdapter, +} from "../src/cli/connect/index.js"; +import type { ConnectAdapter } from "../src/cli/connect/types.js"; + +const EXPECTED_COPILOT_MCP_COMMAND = + process.platform === "win32" + ? { + command: process.env["ComSpec"] || process.env["COMSPEC"] || "cmd.exe", + args: ["/d", "/s", "/c", "npx", "-y", "@agentmemory/mcp"], + } + : { + command: "npx", + args: ["-y", "@agentmemory/mcp"], + }; + +describe("agentmemory connect — dispatcher", () => { + it("resolves every known agent by lowercase name", () => { + for (const name of knownAgents()) { + const a = resolveAdapter(name); + expect(a, `expected adapter for ${name}`).not.toBeNull(); + expect(a!.name).toBe(name); + } + }); + + it("resolves case-insensitively", () => { + expect(resolveAdapter("Claude-Code")?.name).toBe("claude-code"); + expect(resolveAdapter("CURSOR")?.name).toBe("cursor"); + }); + + it("returns null for unknown agents", () => { + expect(resolveAdapter("nonexistent-agent")).toBeNull(); + expect(resolveAdapter("")).toBeNull(); + }); + + it("ships the supported agent list", () => { + expect(knownAgents().sort()).toEqual( + [ + "antigravity", + "claude-code", + "cline", + "copilot-cli", + "codex", + "continue", + "cursor", + "droid", + "gemini-cli", + "hermes", + "kiro", + "opencode", + "openclaw", + "openhuman", + "pi", + "qwen", + "warp", + "zed", + ].sort(), + ); + expect(ADAPTERS.length).toBe(18); + }); + + it("every adapter exposes detect() and install()", () => { + for (const a of ADAPTERS) { + expect(typeof a.detect).toBe("function"); + expect(typeof a.install).toBe("function"); + expect(typeof a.name).toBe("string"); + expect(typeof a.displayName).toBe("string"); + } + }); + + it("every adapter declares a category so onboarding never needs a separate list (#872)", () => { + for (const a of ADAPTERS) { + expect( + ["native", "mcp"].includes(a.category as string), + `adapter ${a.name} must set category to "native" or "mcp"`, + ).toBe(true); + } + }); +}); + +describe("agentmemory connect — claude-code adapter (mock filesystem)", () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalUserprofile: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "am-connect-")); + originalHome = process.env["HOME"]; + originalUserprofile = process.env["USERPROFILE"]; + process.env["HOME"] = tmpHome; + process.env["USERPROFILE"] = tmpHome; + vi.resetModules(); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env["HOME"] = originalHome; + else delete process.env["HOME"]; + if (originalUserprofile !== undefined) + process.env["USERPROFILE"] = originalUserprofile; + else delete process.env["USERPROFILE"]; + rmSync(tmpHome, { recursive: true, force: true }); + vi.resetModules(); + }); + + async function loadAdapter(): Promise { + const mod = await import("../src/cli/connect/claude-code.js?t=" + Date.now()); + return (mod as { adapter: ConnectAdapter }).adapter; + } + + it("detect() returns false when ~/.claude doesn't exist", async () => { + const a = await loadAdapter(); + expect(a.detect()).toBe(false); + }); + + it("install() writes mcpServers.agentmemory into ~/.claude.json and is idempotent", async () => { + const claudeDir = join(tmpHome, ".claude"); + require("node:fs").mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(tmpHome, ".claude.json"), + JSON.stringify({ mcpServers: { other: { command: "x" } } }), + ); + + const a = await loadAdapter(); + expect(a.detect()).toBe(true); + + const first = await a.install({ dryRun: false, force: false }); + expect(first.kind).toBe("installed"); + + const config = JSON.parse(readFileSync(join(tmpHome, ".claude.json"), "utf-8")); + expect(config.mcpServers.agentmemory.command).toBe("npx"); + expect(config.mcpServers.agentmemory.args).toContain("@agentmemory/mcp"); + expect(config.mcpServers.other.command).toBe("x"); + + const second = await a.install({ dryRun: false, force: false }); + expect(second.kind).toBe("already-wired"); + }); + + it("install() writes env passthrough block for AGENTMEMORY_URL + AGENTMEMORY_SECRET (#375)", async () => { + // Remote deployments (k8s, reverse proxy) set AGENTMEMORY_URL + + // AGENTMEMORY_SECRET in the shell. The wired MCP entry must honour + // those via ${VAR} expansion so a single entry covers both local + // and remote without the user needing to add a duplicate config + // that triggers a /doctor duplicate-server warning. + const claudeDir = join(tmpHome, ".claude"); + require("node:fs").mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(tmpHome, ".claude.json"), JSON.stringify({})); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + + const config = JSON.parse(readFileSync(join(tmpHome, ".claude.json"), "utf-8")); + const entry = config.mcpServers.agentmemory; + expect(entry.env).toBeDefined(); + // env interpolation must carry a default so Claude Code + // doesn't silently drop the server when the user hasn't exported + // AGENTMEMORY_URL / AGENTMEMORY_SECRET. Defaults match the + // documented runtime (localhost:3111, no auth, all tools). + expect(entry.env.AGENTMEMORY_URL).toBe( + "${AGENTMEMORY_URL:-http://localhost:3111}", + ); + expect(entry.env.AGENTMEMORY_SECRET).toBe("${AGENTMEMORY_SECRET:-}"); + expect(entry.env.AGENTMEMORY_TOOLS).toBe("${AGENTMEMORY_TOOLS:-all}"); + }); + + it("install() with --force re-writes even when already wired", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude.json"), + JSON.stringify({ + mcpServers: { + agentmemory: { command: "npx", args: ["-y", "@agentmemory/mcp"] }, + }, + }), + ); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: true }); + expect(result.kind).toBe("installed"); + }); + + it("install() with --dry-run does not mutate the file", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + const before = JSON.stringify({ mcpServers: {} }); + writeFileSync(join(tmpHome, ".claude.json"), before); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: true, force: false }); + expect(result.kind).toBe("installed"); + + const after = readFileSync(join(tmpHome, ".claude.json"), "utf-8"); + expect(after).toBe(before); + }); + + it("install() creates a backup file under ~/.agentmemory/backups/", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".claude"), { recursive: true }); + writeFileSync( + join(tmpHome, ".claude.json"), + JSON.stringify({ mcpServers: {} }), + ); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + if (result.kind === "installed") { + expect(result.backupPath).toBeDefined(); + expect(existsSync(result.backupPath!)).toBe(true); + expect(result.backupPath!).toContain(join(".agentmemory", "backups")); + } + }); +}); + +describe("agentmemory connect — opencode adapter (#872)", () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalUserprofile: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "am-opencode-")); + originalHome = process.env["HOME"]; + originalUserprofile = process.env["USERPROFILE"]; + process.env["HOME"] = tmpHome; + process.env["USERPROFILE"] = tmpHome; + vi.resetModules(); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env["HOME"] = originalHome; + else delete process.env["HOME"]; + if (originalUserprofile !== undefined) + process.env["USERPROFILE"] = originalUserprofile; + else delete process.env["USERPROFILE"]; + rmSync(tmpHome, { recursive: true, force: true }); + vi.resetModules(); + }); + + const cfgPath = () => + join(tmpHome, ".config", "opencode", "opencode.json"); + + async function loadOpencode(): Promise { + const mod = await import("../src/cli/connect/opencode.js?t=" + Date.now()); + return (mod as { adapter: ConnectAdapter }).adapter; + } + + it("writes the opencode `mcp` schema (command as array) and preserves other servers", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".config", "opencode"), { + recursive: true, + }); + writeFileSync( + cfgPath(), + JSON.stringify({ mcp: { other: { type: "local", command: ["x"] } } }), + ); + + const a = await loadOpencode(); + expect(a.name).toBe("opencode"); + expect(a.detect()).toBe(true); + + const first = await a.install({ dryRun: false, force: false }); + expect(first.kind).toBe("installed"); + + const config = JSON.parse(readFileSync(cfgPath(), "utf-8")); + const entry = config.mcp.agentmemory; + expect(entry.type).toBe("local"); + expect(Array.isArray(entry.command)).toBe(true); + expect(entry.command).toContain("@agentmemory/mcp"); + expect(entry.enabled).toBe(true); + expect(config.mcp.other.command).toEqual(["x"]); + + const second = await a.install({ dryRun: false, force: false }); + expect(second.kind).toBe("already-wired"); + }); + + it("dry-run does not mutate the file", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".config", "opencode"), { + recursive: true, + }); + const before = JSON.stringify({ mcp: {} }); + writeFileSync(cfgPath(), before); + + const a = await loadOpencode(); + const result = await a.install({ dryRun: true, force: false }); + expect(result.kind).toBe("installed"); + expect(readFileSync(cfgPath(), "utf-8")).toBe(before); + }); +}); + +describe("agentmemory connect — copilot-cli adapter (mock filesystem)", () => { + let tmpHome: string; + let originalHome: string | undefined; + let originalUserprofile: string | undefined; + let originalCopilotHome: string | undefined; + let importCounter = 0; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), "am-connect-")); + originalHome = process.env["HOME"]; + originalUserprofile = process.env["USERPROFILE"]; + originalCopilotHome = process.env["COPILOT_HOME"]; + process.env["HOME"] = tmpHome; + process.env["USERPROFILE"] = tmpHome; + delete process.env["COPILOT_HOME"]; + vi.resetModules(); + }); + + afterEach(() => { + if (originalHome !== undefined) process.env["HOME"] = originalHome; + else delete process.env["HOME"]; + if (originalUserprofile !== undefined) + process.env["USERPROFILE"] = originalUserprofile; + else delete process.env["USERPROFILE"]; + if (originalCopilotHome !== undefined) + process.env["COPILOT_HOME"] = originalCopilotHome; + else delete process.env["COPILOT_HOME"]; + rmSync(tmpHome, { recursive: true, force: true }); + vi.resetModules(); + }); + + async function loadAdapter(): Promise { + const mod = await import( + "../src/cli/connect/copilot-cli.js?t=" + Date.now() + "-" + importCounter++ + ); + return (mod as { adapter: ConnectAdapter }).adapter; + } + + it("detect() returns false when ~/.copilot doesn't exist", async () => { + const a = await loadAdapter(); + expect(a.detect()).toBe(false); + }); + + it("install() writes mcpServers.agentmemory into ~/.copilot/mcp-config.json and is idempotent", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true }); + + const a = await loadAdapter(); + expect(a.detect()).toBe(true); + + const first = await a.install({ dryRun: false, force: false }); + expect(first.kind).toBe("installed"); + + const config = JSON.parse( + readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"), + ); + expect(config.mcpServers.agentmemory).toEqual({ + type: "local", + ...EXPECTED_COPILOT_MCP_COMMAND, + env: { + AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}", + AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}", + AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}", + }, + tools: ["*"], + }); + + const second = await a.install({ dryRun: false, force: false }); + expect(second.kind).toBe("already-wired"); + }); + + it("honors COPILOT_HOME when locating mcp-config.json", async () => { + const customCopilotHome = join(tmpHome, "custom-copilot-home"); + process.env["COPILOT_HOME"] = customCopilotHome; + require("node:fs").mkdirSync(customCopilotHome, { recursive: true }); + + const a = await loadAdapter(); + expect(a.detect()).toBe(true); + + const result = await a.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + expect(result.mutatedPath).toBe(join(customCopilotHome, "mcp-config.json")); + expect(existsSync(join(customCopilotHome, "mcp-config.json"))).toBe(true); + expect(existsSync(join(tmpHome, ".copilot", "mcp-config.json"))).toBe(false); + }); + + it("install() preserves unrelated top-level keys and mcpServers entries", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true }); + writeFileSync( + join(tmpHome, ".copilot", "mcp-config.json"), + JSON.stringify({ + otherTopLevel: { keep: true }, + mcpServers: { other: { type: "local", command: "other" } }, + }), + ); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + + const config = JSON.parse( + readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"), + ); + expect(config.otherTopLevel).toEqual({ keep: true }); + expect(config.mcpServers.other).toEqual({ type: "local", command: "other" }); + expect(config.mcpServers.agentmemory.command).toBe( + EXPECTED_COPILOT_MCP_COMMAND.command, + ); + }); + + it("install() writes env passthrough block for AGENTMEMORY_URL + AGENTMEMORY_SECRET", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true }); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + + const config = JSON.parse( + readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"), + ); + const entry = config.mcpServers.agentmemory; + expect(entry.env.AGENTMEMORY_URL).toBe( + "${AGENTMEMORY_URL:-http://localhost:3111}", + ); + expect(entry.env.AGENTMEMORY_SECRET).toBe("${AGENTMEMORY_SECRET:-}"); + expect(entry.env.AGENTMEMORY_TOOLS).toBe("${AGENTMEMORY_TOOLS:-all}"); + }); + + it("install() with --force rewrites even when already wired", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true }); + writeFileSync( + join(tmpHome, ".copilot", "mcp-config.json"), + JSON.stringify({ + mcpServers: { + agentmemory: { + type: "local", + ...EXPECTED_COPILOT_MCP_COMMAND, + env: { + AGENTMEMORY_URL: "${AGENTMEMORY_URL:-http://localhost:3111}", + AGENTMEMORY_SECRET: "${AGENTMEMORY_SECRET:-}", + AGENTMEMORY_TOOLS: "${AGENTMEMORY_TOOLS:-all}", + }, + tools: ["memory_save"], + }, + }, + }), + ); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: true }); + expect(result.kind).toBe("installed"); + + const config = JSON.parse( + readFileSync(join(tmpHome, ".copilot", "mcp-config.json"), "utf-8"), + ); + expect(config.mcpServers.agentmemory.tools).toEqual(["*"]); + }); + + it("install() with --dry-run does not mutate the file", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true }); + const before = JSON.stringify({ mcpServers: {} }); + writeFileSync(join(tmpHome, ".copilot", "mcp-config.json"), before); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: true, force: false }); + expect(result.kind).toBe("installed"); + + const after = readFileSync( + join(tmpHome, ".copilot", "mcp-config.json"), + "utf-8", + ); + expect(after).toBe(before); + }); + + it("install() creates a backup file when config pre-exists", async () => { + require("node:fs").mkdirSync(join(tmpHome, ".copilot"), { recursive: true }); + writeFileSync( + join(tmpHome, ".copilot", "mcp-config.json"), + JSON.stringify({ mcpServers: {} }), + ); + + const a = await loadAdapter(); + const result = await a.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + if (result.kind === "installed") { + expect(result.backupPath).toBeDefined(); + expect(existsSync(result.backupPath!)).toBe(true); + expect(result.backupPath!).toContain(join(".agentmemory", "backups")); + } + }); +}); + +describe("agentmemory connect — stub adapters log + return stub", () => { + it("hermes adapter returns stub regardless of detect", async () => { + const { adapter } = await import("../src/cli/connect/hermes.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("stub"); + }); + + it("openhuman adapter returns stub", async () => { + const { adapter } = await import("../src/cli/connect/openhuman.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("stub"); + }); + + it("pi adapter returns stub", async () => { + const { adapter } = await import("../src/cli/connect/pi.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("stub"); + }); +}); diff --git a/test/cli-doctor-fixes.test.ts b/test/cli-doctor-fixes.test.ts new file mode 100644 index 0000000..f7d671f --- /dev/null +++ b/test/cli-doctor-fixes.test.ts @@ -0,0 +1,239 @@ +// Unit tests for the doctor v2 diagnostic catalog. +// +// We exercise the data structure (every entry has check/fix/message), +// the pure parseEnvFile / realProviderKeys helpers, and the dry-run plan +// formatting. The full interactive prompt loop lives in src/cli.ts and is +// driven by clack — exercising it would require a TTY and is out of scope. + +import { describe, it, expect } from "vitest"; +import { + buildDiagnostics, + DIAGNOSTIC_IDS, + dryRunPlan, + parseEnvFile, + placeholderProviderKeys, + realProviderKeys, + type DoctorContext, + type DoctorEffects, +} from "../src/cli/doctor-diagnostics.js"; + +function stubCtx(overrides: Partial = {}): DoctorContext { + return { + baseUrl: "http://localhost:3111", + viewerUrl: "http://localhost:3113", + envPath: "/tmp/test/.agentmemory/.env", + pidfilePath: "/tmp/test/.agentmemory/iii.pid", + enginePath: "/tmp/test/.agentmemory/engine-state.json", + pinnedVersion: "0.11.2", + ...overrides, + }; +} + +function stubEffects(overrides: Partial = {}): DoctorEffects { + return { + envFileExists: () => true, + readEnvFile: () => ({ ANTHROPIC_API_KEY: "sk-ant-real-key-value" }), + pidfileExists: () => false, + pidfilePidIsAlive: () => null, + findIiiBinary: () => "/Users/test/.local/bin/iii", + localBinIiiPath: () => "/Users/test/.local/bin/iii", + iiiBinaryVersion: () => "0.11.2", + viewerReachable: async () => true, + runInit: async () => ({ ok: true, message: "wrote .env" }), + openEditor: async () => ({ ok: true, message: "saved" }), + runIiiInstaller: async () => ({ ok: true, message: "installed" }), + runStop: async () => ({ ok: true, message: "stopped" }), + runStart: async () => ({ ok: true, message: "started" }), + clearEnginePidAndState: () => {}, + ...overrides, + }; +} + +describe("doctor v2 diagnostic catalog", () => { + it("exports a stable list of diagnostic ids", () => { + expect(DIAGNOSTIC_IDS).toContain("env-missing"); + expect(DIAGNOSTIC_IDS).toContain("no-llm-provider-key"); + expect(DIAGNOSTIC_IDS).toContain("engine-version-mismatch"); + expect(DIAGNOSTIC_IDS).toContain("viewer-unreachable"); + expect(DIAGNOSTIC_IDS).toContain("stale-pidfile"); + expect(DIAGNOSTIC_IDS).toContain("env-placeholder-keys"); + expect(DIAGNOSTIC_IDS).toContain("iii-on-path-not-local-bin"); + }); + + it("every diagnostic has check, fix, message, and fixPreview", () => { + const diagnostics = buildDiagnostics(stubEffects()); + expect(diagnostics.length).toBe(DIAGNOSTIC_IDS.length); + for (const d of diagnostics) { + expect(d.id).toMatch(/^[a-z][a-z0-9-]+$/); + expect(d.message.length).toBeGreaterThan(0); + expect(d.fixPreview.length).toBeGreaterThan(0); + expect(d.moreInfo.length).toBeGreaterThan(0); + expect(typeof d.check).toBe("function"); + expect(typeof d.fix).toBe("function"); + } + }); + + it("diagnostic ids are unique", () => { + const diagnostics = buildDiagnostics(stubEffects()); + const ids = diagnostics.map((d) => d.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("env-missing fails when env file is absent", async () => { + const diagnostics = buildDiagnostics(stubEffects({ envFileExists: () => false })); + const envCheck = diagnostics.find((d) => d.id === "env-missing")!; + const status = await envCheck.check(stubCtx()); + expect(status.ok).toBe(false); + }); + + it("env-missing passes when env file exists", async () => { + const diagnostics = buildDiagnostics(stubEffects({ envFileExists: () => true })); + const envCheck = diagnostics.find((d) => d.id === "env-missing")!; + const status = await envCheck.check(stubCtx()); + expect(status.ok).toBe(true); + }); + + it("no-llm-provider-key fails when env has only placeholders", async () => { + const diagnostics = buildDiagnostics( + stubEffects({ + envFileExists: () => true, + readEnvFile: () => ({ ANTHROPIC_API_KEY: "your-key-here" }), + }), + ); + const check = diagnostics.find((d) => d.id === "no-llm-provider-key")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(false); + }); + + it("no-llm-provider-key passes when one real key is set", async () => { + const diagnostics = buildDiagnostics(stubEffects()); + const check = diagnostics.find((d) => d.id === "no-llm-provider-key")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(true); + }); + + it("engine-version-mismatch fails when iii reports the wrong version", async () => { + const diagnostics = buildDiagnostics( + stubEffects({ iiiBinaryVersion: () => "0.99.99" }), + ); + const check = diagnostics.find((d) => d.id === "engine-version-mismatch")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(false); + expect(status.detail).toContain("0.99.99"); + expect(status.detail).toContain("0.11.2"); + }); + + it("engine-version-mismatch passes when iii matches pinned version", async () => { + const diagnostics = buildDiagnostics(stubEffects()); + const check = diagnostics.find((d) => d.id === "engine-version-mismatch")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(true); + }); + + it("viewer-unreachable fails when viewer probe returns false", async () => { + const diagnostics = buildDiagnostics( + stubEffects({ viewerReachable: async () => false }), + ); + const check = diagnostics.find((d) => d.id === "viewer-unreachable")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(false); + }); + + it("stale-pidfile passes when no pidfile exists", async () => { + const diagnostics = buildDiagnostics(stubEffects({ pidfileExists: () => false })); + const check = diagnostics.find((d) => d.id === "stale-pidfile")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(true); + }); + + it("stale-pidfile fails when pidfile points at a dead pid", async () => { + const diagnostics = buildDiagnostics( + stubEffects({ pidfileExists: () => true, pidfilePidIsAlive: () => false }), + ); + const check = diagnostics.find((d) => d.id === "stale-pidfile")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(false); + expect(status.detail).toBe("pid is gone"); + }); + + it("env-placeholder-keys detects sk-ant-... placeholder", async () => { + const diagnostics = buildDiagnostics( + stubEffects({ + envFileExists: () => true, + readEnvFile: () => ({ ANTHROPIC_API_KEY: "sk-ant-..." }), + }), + ); + const check = diagnostics.find((d) => d.id === "env-placeholder-keys")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(false); + expect(status.detail).toContain("ANTHROPIC_API_KEY"); + }); + + it("iii-on-path-not-local-bin warns when iii lives in another location", async () => { + const diagnostics = buildDiagnostics( + stubEffects({ + findIiiBinary: () => "/opt/homebrew/bin/iii", + localBinIiiPath: () => "/Users/test/.local/bin/iii", + }), + ); + const check = diagnostics.find((d) => d.id === "iii-on-path-not-local-bin")!; + const status = await check.check(stubCtx()); + expect(status.ok).toBe(false); + expect(check.manualOnly).toBe(true); + }); + + it("dryRunPlan lists each failing diagnostic with the fix preview", () => { + const diagnostics = buildDiagnostics(stubEffects()); + const results = diagnostics.map((d) => ({ + diagnostic: d, + status: { ok: false, detail: "stub fail" }, + })); + const lines = dryRunPlan(stubCtx(), results); + expect(lines.some((l) => l.includes("env-missing"))).toBe(true); + expect(lines.some((l) => l.includes("would fix:"))).toBe(true); + }); + + it("dryRunPlan reports all-passing state", () => { + const diagnostics = buildDiagnostics(stubEffects()); + const results = diagnostics.map((d) => ({ + diagnostic: d, + status: { ok: true }, + })); + const lines = dryRunPlan(stubCtx(), results); + expect(lines.length).toBe(1); + expect(lines[0]).toContain("All checks passing"); + }); +}); + +describe("parseEnvFile", () => { + it("strips comments and blank lines", () => { + const env = parseEnvFile("# a comment\n\nFOO=bar\nBAZ=qux\n"); + expect(env).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + it("strips surrounding quotes", () => { + const env = parseEnvFile(`A="hello"\nB='world'\nC=plain\n`); + expect(env).toEqual({ A: "hello", B: "world", C: "plain" }); + }); +}); + +describe("realProviderKeys / placeholderProviderKeys", () => { + it("returns real keys only", () => { + const env = { + ANTHROPIC_API_KEY: "sk-ant-real-value", + OPENAI_API_KEY: "sk-...", + GEMINI_API_KEY: "", + OPENROUTER_API_KEY: "your-key-here", + }; + expect(realProviderKeys(env)).toEqual(["ANTHROPIC_API_KEY"]); + expect(placeholderProviderKeys(env)).toContain("OPENAI_API_KEY"); + expect(placeholderProviderKeys(env)).toContain("OPENROUTER_API_KEY"); + expect(placeholderProviderKeys(env)).not.toContain("GEMINI_API_KEY"); + }); + + it("treats xxx-style placeholders as fake", () => { + expect(placeholderProviderKeys({ ANTHROPIC_API_KEY: "xxxx-xxxx" })).toEqual([ + "ANTHROPIC_API_KEY", + ]); + }); +}); diff --git a/test/cli-onboarding.test.ts b/test/cli-onboarding.test.ts new file mode 100644 index 0000000..9779a7e --- /dev/null +++ b/test/cli-onboarding.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const prompts = vi.hoisted(() => ({ + note: vi.fn(), + multiselect: vi.fn(async () => { + throw new Error("interactive multiselect should not run in non-TTY onboarding"); + }), + select: vi.fn(async () => { + throw new Error("interactive select should not run in non-TTY onboarding"); + }), + confirm: vi.fn(async () => true), + isCancel: vi.fn(() => false), + cancel: vi.fn(), + log: { + warn: vi.fn(), + step: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@clack/prompts", () => prompts); +vi.mock("../src/cli/connect/index.js", () => ({ + resolveAdapter: vi.fn(), + runAdapter: vi.fn(), +})); + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; +const stdinTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +const stdoutTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + +let sandboxHome: string; + +function setTTY(value: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { value, configurable: true }); + Object.defineProperty(process.stdout, "isTTY", { value, configurable: true }); +} + +function restoreTTY(): void { + if (stdinTtyDescriptor) Object.defineProperty(process.stdin, "isTTY", stdinTtyDescriptor); + else delete (process.stdin as NodeJS.ReadStream & { isTTY?: boolean }).isTTY; + if (stdoutTtyDescriptor) Object.defineProperty(process.stdout, "isTTY", stdoutTtyDescriptor); + else delete (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY; +} + +async function freshOnboarding() { + vi.resetModules(); + return await import("../src/cli/onboarding.js"); +} + +describe("cli onboarding", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-onboarding-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + setTTY(false); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreTTY(); + if (ORIGINAL_HOME === undefined) delete process.env["HOME"]; + else process.env["HOME"] = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE; + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + it("does not prompt and records default preferences when onboarding runs without a TTY", async () => { + const { runOnboarding } = await freshOnboarding(); + + const result = await runOnboarding(); + + expect(result).toEqual({ agents: [], provider: null }); + expect(prompts.multiselect).not.toHaveBeenCalled(); + expect(prompts.select).not.toHaveBeenCalled(); + expect(prompts.confirm).not.toHaveBeenCalled(); + + const preferencesPath = join(sandboxHome, ".agentmemory", "preferences.json"); + expect(existsSync(preferencesPath)).toBe(true); + const preferences = JSON.parse(readFileSync(preferencesPath, "utf-8")); + expect(preferences).toMatchObject({ + schemaVersion: 1, + lastAgent: null, + lastAgents: [], + lastProvider: null, + skipSplash: true, + }); + expect(typeof preferences.firstRunAt).toBe("string"); + }); +}); diff --git a/test/cli-remove.test.ts b/test/cli-remove.test.ts new file mode 100644 index 0000000..2484d4f --- /dev/null +++ b/test/cli-remove.test.ts @@ -0,0 +1,169 @@ +// Unit tests for the `agentmemory remove` destruction plan. +// +// The plan module is pure-fs (just inspects what's present) so we sandbox +// a fake $HOME under tmpdir() and assert which plan items come back. The +// actual file deletion is wrapped in src/cli.ts. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + buildRemovePlan, + formatPlan, + type ConnectManifest, + type RemoveContext, +} from "../src/cli/remove-plan.js"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let sandbox: string; + +function ctx(overrides: Partial = {}): RemoveContext { + return { + home: sandbox, + pinnedVersion: "0.11.2", + localBinIiiVersion: null, + connectManifest: null, + ...overrides, + }; +} + +function touch(relPath: string, content = ""): void { + const full = join(sandbox, relPath); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content); +} + +function mkdir(relPath: string): void { + mkdirSync(join(sandbox, relPath), { recursive: true }); +} + +beforeEach(() => { + sandbox = mkdtempSync(join(tmpdir(), "agentmemory-remove-")); +}); + +afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }); +}); + +describe("buildRemovePlan", () => { + it("returns no applicable items on a clean system", () => { + const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); + const applicable = plan.filter((p) => p.applicable); + expect(applicable.length).toBe(0); + }); + + it("includes pidfile + engine-state when both exist", () => { + touch(".agentmemory/iii.pid", "12345\n"); + touch(".agentmemory/engine-state.json", "{}"); + const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); + const ids = plan.filter((p) => p.applicable).map((p) => p.id); + expect(ids).toContain("stop-engine"); + expect(ids).toContain("pidfile"); + expect(ids).toContain("engine-state"); + }); + + it("marks .env as alwaysAsk", () => { + touch(".agentmemory/.env", "ANTHROPIC_API_KEY=sk-ant-real\n"); + const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); + const envItem = plan.find((p) => p.id === "env")!; + expect(envItem.applicable).toBe(true); + expect(envItem.alwaysAsk).toBe(true); + }); + + it("--keep-data hides .env, preferences, backups, and data-dir", () => { + touch(".agentmemory/.env", "x"); + touch(".agentmemory/preferences.json", "{}"); + mkdir(".agentmemory/backups"); + mkdir(".agentmemory/data"); + const plan = buildRemovePlan(ctx(), { force: false, keepData: true }); + const applicable = plan.filter((p) => p.applicable).map((p) => p.id); + expect(applicable).not.toContain("env"); + expect(applicable).not.toContain("preferences"); + expect(applicable).not.toContain("backups"); + expect(applicable).not.toContain("data-dir"); + }); + + it("data-dir is alwaysAsk even on --force", () => { + mkdir(".agentmemory/data"); + const plan = buildRemovePlan(ctx(), { force: true, keepData: false }); + const item = plan.find((p) => p.id === "data-dir")!; + expect(item.applicable).toBe(true); + expect(item.alwaysAsk).toBe(true); + }); + + it("expands connect-manifest entries into individual plan items", () => { + const manifest: ConnectManifest = { + installed: [ + { target: join(sandbox, "fake-claude-symlink"), agent: "claude-code", symlink: true }, + { target: join(sandbox, "fake-cursor-link"), agent: "cursor" }, + ], + }; + touch("fake-claude-symlink"); + touch("fake-cursor-link"); + const plan = buildRemovePlan(ctx({ connectManifest: manifest }), { + force: false, + keepData: false, + }); + const connectItems = plan.filter((p) => p.id.startsWith("connect:")); + expect(connectItems.length).toBe(2); + expect(connectItems.every((p) => p.applicable)).toBe(true); + }); + + it("local-bin/iii is alwaysAsk when version does not match", () => { + touch(".local/bin/iii", "fakebin"); + const plan = buildRemovePlan( + ctx({ localBinIiiVersion: "9.9.9" }), + { force: false, keepData: false }, + ); + const item = plan.find((p) => p.id === "legacy-local-bin-iii")!; + expect(item.applicable).toBe(true); + expect(item.alwaysAsk).toBe(true); + }); + + it("local-bin/iii is auto-fixable when version matches pinned", () => { + touch(".local/bin/iii", "fakebin"); + const plan = buildRemovePlan( + ctx({ localBinIiiVersion: "0.11.2" }), + { force: false, keepData: false }, + ); + const item = plan.find((p) => p.id === "legacy-local-bin-iii")!; + expect(item.applicable).toBe(true); + expect(item.alwaysAsk).toBe(false); + expect(item.description).toContain("matches pinned"); + }); + + it("local-bin/iii absent: no plan entry created", () => { + const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); + expect(plan.find((p) => p.id === "legacy-local-bin-iii")).toBeUndefined(); + expect(plan.find((p) => p.id === "private-bin-iii")).toBeUndefined(); + }); + + it("private ~/.agentmemory/bin/iii is removed without prompt", () => { + touch(".agentmemory/bin/iii", "fakebin"); + const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); + const item = plan.find((p) => p.id === "private-bin-iii")!; + expect(item).toBeDefined(); + expect(item.applicable).toBe(true); + expect(item.alwaysAsk).toBe(false); + expect(item.description).toContain("private install"); + }); +}); + +describe("formatPlan", () => { + it("renders applicable items with numbers", () => { + touch(".agentmemory/iii.pid", "1"); + touch(".agentmemory/engine-state.json", "{}"); + const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); + const out = formatPlan(plan); + expect(out).toMatch(/^\s+1\./m); + expect(out).toContain("pidfile"); + expect(out).toContain("engine-state.json"); + }); + + it("marks alwaysAsk items with [asks]", () => { + touch(".agentmemory/.env", "x"); + const plan = buildRemovePlan(ctx(), { force: false, keepData: false }); + const out = formatPlan(plan); + expect(out).toContain("[asks]"); + }); +}); diff --git a/test/codex-connect-hooks.test.ts b/test/codex-connect-hooks.test.ts new file mode 100644 index 0000000..75accbe --- /dev/null +++ b/test/codex-connect-hooks.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { + buildMergedHooks, + findPluginRoot, + type HookManifest, +} from "../src/cli/connect/codex-hooks.js"; + +const PLUGIN_ROOT = resolve(__dirname, "..", "plugin"); + +describe("findPluginRoot", () => { + it("locates the bundled plugin/ directory from src/cli/connect/", () => { + const root = findPluginRoot(); + expect(root).toBe(PLUGIN_ROOT); + }); +}); + +describe("buildMergedHooks", () => { + it("rewrites ${CLAUDE_PLUGIN_ROOT} to absolute pluginRoot in every command", () => { + const merged = buildMergedHooks(null, PLUGIN_ROOT); + for (const entries of Object.values(merged.hooks)) { + for (const entry of entries) { + for (const handler of entry.hooks) { + expect(handler.command).not.toContain("${CLAUDE_PLUGIN_ROOT}"); + expect(handler.command).toContain(`${PLUGIN_ROOT}/scripts/`); + } + } + } + }); + + it("preserves matchers from the bundled manifest (e.g. PreToolUse)", () => { + const merged = buildMergedHooks(null, PLUGIN_ROOT); + const preToolUse = merged.hooks["PreToolUse"]; + expect(preToolUse).toBeDefined(); + expect(preToolUse!.length).toBeGreaterThan(0); + expect(preToolUse![0].matcher).toBe("Edit|Write|Read|Glob|Grep"); + }); + + it("includes all six expected lifecycle events", () => { + const merged = buildMergedHooks(null, PLUGIN_ROOT); + for (const event of [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PreCompact", + "Stop", + ]) { + expect(Object.keys(merged.hooks)).toContain(event); + } + }); + + it("appends to existing user hooks without dropping them", () => { + const existing: HookManifest = { + hooks: { + SessionStart: [ + { + hooks: [{ type: "command", command: "echo user-custom" }], + }, + ], + UserPromptSubmit: [ + { + hooks: [{ type: "command", command: "echo another-user-hook" }], + }, + ], + }, + }; + const merged = buildMergedHooks(existing, PLUGIN_ROOT); + const sessionStart = merged.hooks["SessionStart"]!; + const userHook = sessionStart.find((e) => + e.hooks.some((h) => h.command === "echo user-custom"), + ); + expect(userHook, "user's SessionStart hook should survive").toBeDefined(); + const ours = sessionStart.find((e) => + e.hooks.some((h) => h.command.includes(`${PLUGIN_ROOT}/scripts/session-start.mjs`)), + ); + expect(ours, "agentmemory SessionStart hook should be appended").toBeDefined(); + }); + + it("re-install strips previous agentmemory entries (idempotent by script path)", () => { + const first = buildMergedHooks(null, PLUGIN_ROOT); + const second = buildMergedHooks(first, PLUGIN_ROOT); + for (const event of Object.keys(first.hooks)) { + expect( + second.hooks[event]!.length, + `${event} should not double after second install`, + ).toBe(first.hooks[event]!.length); + } + }); + + it("re-install preserves unrelated user entries", () => { + const userEntry = { + hooks: [{ type: "command", command: "echo user-untouchable" }], + }; + const withUser: HookManifest = { + hooks: { + SessionStart: [userEntry], + Stop: [{ hooks: [{ type: "command", command: "echo also-user" }] }], + }, + }; + const installed = buildMergedHooks(withUser, PLUGIN_ROOT); + const reinstalled = buildMergedHooks(installed, PLUGIN_ROOT); + expect( + reinstalled.hooks["SessionStart"]!.some((e) => + e.hooks.some((h) => h.command === "echo user-untouchable"), + ), + ).toBe(true); + expect( + reinstalled.hooks["Stop"]!.some((e) => + e.hooks.some((h) => h.command === "echo also-user"), + ), + ).toBe(true); + }); + + it("handles empty existing manifest object", () => { + const merged = buildMergedHooks({ hooks: {} }, PLUGIN_ROOT); + expect(Object.keys(merged.hooks).length).toBeGreaterThan(0); + }); +}); + +describe("buildMergedHooks file round-trip", () => { + it("produces JSON that parses back to a structurally equivalent manifest", () => { + const dir = join(tmpdir(), `agentmemory-codex-hooks-${process.pid}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "hooks.json"); + try { + const merged = buildMergedHooks(null, PLUGIN_ROOT); + writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf-8"); + const reread = JSON.parse(readFileSync(path, "utf-8")) as HookManifest; + expect(Object.keys(reread.hooks).sort()).toEqual(Object.keys(merged.hooks).sort()); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/codex-plugin.test.ts b/test/codex-plugin.test.ts new file mode 100644 index 0000000..6b71119 --- /dev/null +++ b/test/codex-plugin.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const repoRoot = resolve(__dirname, ".."); +const pluginRoot = join(repoRoot, "plugin"); + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, "utf-8")) as T; +} + +type HookHandler = { type: string; command: string }; +type HookEntry = { hooks: HookHandler[] }; + +function hookCommands(path: string): string[] { + const manifest = readJson<{ hooks: Record }>(path); + return Object.values(manifest.hooks).flatMap((entries) => + entries.flatMap((entry) => entry.hooks.map((handler) => handler.command)), + ); +} + +describe("Plugin hook manifests", () => { + it("quote plugin script paths so roots with spaces stay intact", () => { + for (const manifest of ["hooks.json", "hooks.codex.json"]) { + const commands = hookCommands(join(pluginRoot, "hooks", manifest)); + expect(commands.length, `${manifest} should contain hook commands`).toBeGreaterThan(0); + + for (const command of commands) { + expect(command).toMatch(/^node "\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/[^\s"]+\.mjs"$/); + } + } + }); +}); + +describe("Codex plugin manifest (developers.openai.com/codex/plugins)", () => { + it("ships .codex-plugin/plugin.json with kebab-case name + version + references", () => { + const manifestPath = join(pluginRoot, ".codex-plugin/plugin.json"); + expect(existsSync(manifestPath)).toBe(true); + const manifest = readJson<{ + name: string; + version: string; + description?: string; + skills?: string; + mcpServers?: string; + hooks?: string; + }>(manifestPath); + expect(manifest.name).toBe("agentmemory"); + expect(manifest.name).toMatch(/^[a-z][a-z0-9-]*$/); + expect(manifest.version).toMatch(/^\d+\.\d+\.\d+/); + expect(manifest.skills).toBeDefined(); + expect(manifest.mcpServers).toBeDefined(); + expect(manifest.hooks).toBeDefined(); + }); + + it("manifest version matches main package.json", () => { + const pkgVer = readJson<{ version: string }>(join(repoRoot, "package.json")).version; + const codexVer = readJson<{ version: string }>( + join(pluginRoot, ".codex-plugin/plugin.json"), + ).version; + expect(codexVer).toBe(pkgVer); + }); + + it("all referenced manifest paths resolve to existing files / directories", () => { + const manifest = readJson<{ skills: string; mcpServers: string; hooks: string }>( + join(pluginRoot, ".codex-plugin/plugin.json"), + ); + expect(existsSync(join(pluginRoot, manifest.skills))).toBe(true); + expect(existsSync(join(pluginRoot, manifest.mcpServers))).toBe(true); + expect(existsSync(join(pluginRoot, manifest.hooks))).toBe(true); + }); + + it("plugin MCP server inherits remote agentmemory environment overrides", () => { + const mcp = readJson<{ + mcpServers: Record< + string, + { + command: string; + args: string[]; + env?: Record; + } + >; + }>(join(pluginRoot, ".mcp.json")); + + // env interpolation must include defaults so Claude Code (and + // any other MCP host that fails parse on unset ${VAR}) doesn't drop + // the server silently when the user hasn't exported the var. + expect(mcp.mcpServers.agentmemory?.env?.AGENTMEMORY_URL).toMatch( + /\$\{AGENTMEMORY_URL:-/, + ); + expect(mcp.mcpServers.agentmemory?.env?.AGENTMEMORY_SECRET).toMatch( + /\$\{AGENTMEMORY_SECRET:-/, + ); + }); + + it("hooks.codex.json contains only events Codex supports (no Subagent / SessionEnd / Notification / TaskCompleted / PostToolUseFailure)", () => { + const hooksPath = join(pluginRoot, "hooks/hooks.codex.json"); + const hooks = readJson<{ hooks: Record }>(hooksPath); + const events = Object.keys(hooks.hooks); + const codexSupported = new Set([ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PermissionRequest", + "PreCompact", + "PostCompact", + "Stop", + ]); + for (const event of events) { + expect(codexSupported.has(event), `unexpected event "${event}" in hooks.codex.json`).toBe(true); + } + expect(events).toContain("SessionStart"); + expect(events).toContain("UserPromptSubmit"); + expect(events).toContain("PreToolUse"); + expect(events).toContain("PostToolUse"); + expect(events).toContain("PreCompact"); + expect(events).toContain("Stop"); + }); + + it("hook command scripts referenced in hooks.codex.json exist on disk", () => { + const hooks = readJson<{ hooks: Record }>( + join(pluginRoot, "hooks/hooks.codex.json"), + ); + const scriptRefs = new Set(); + for (const entries of Object.values(hooks.hooks)) { + for (const entry of entries) { + for (const handler of entry.hooks) { + const match = handler.command.match(/\$\{CLAUDE_PLUGIN_ROOT\}\/(scripts\/[^\s"]+)/); + if (match) scriptRefs.add(match[1]); + } + } + } + expect(scriptRefs.size).toBeGreaterThan(0); + for (const rel of scriptRefs) { + expect(existsSync(join(pluginRoot, rel)), `missing hook script: ${rel}`).toBe(true); + } + }); +}); + +describe("Codex marketplace.json (.codex-plugin/marketplace.json at repo root)", () => { + it("ships a marketplace manifest pointing at the plugin/ subdirectory", () => { + const marketplacePath = join(repoRoot, ".codex-plugin/marketplace.json"); + expect(existsSync(marketplacePath)).toBe(true); + const marketplace = readJson<{ + name: string; + plugins: Array<{ + name: string; + source: { source: string; url: string; path: string; ref?: string }; + }>; + }>(marketplacePath); + expect(marketplace.name).toBe("agentmemory"); + expect(marketplace.plugins).toHaveLength(1); + const entry = marketplace.plugins[0]; + expect(entry.name).toBe("agentmemory"); + expect(entry.source.source).toBe("git-subdir"); + expect(entry.source.path).toBe("./plugin"); + expect(entry.source.url).toMatch(/rohitg00\/agentmemory/); + }); +}); diff --git a/test/compress-file.test.ts b/test/compress-file.test.ts new file mode 100644 index 0000000..9b6820b --- /dev/null +++ b/test/compress-file.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const fileStore = new Map(); +const symlinkPaths = new Set(); +const openEloopPaths = new Set(); + +vi.mock("node:fs/promises", () => ({ + lstat: vi.fn(async (path: string) => { + if (symlinkPaths.has(path)) { + return { isSymbolicLink: () => true }; + } + if (!fileStore.has(path)) { + throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${path}'`), { + code: "ENOENT", + }); + } + return { isSymbolicLink: () => false }; + }), + open: vi.fn(async (path: string) => { + if (openEloopPaths.has(path)) { + throw Object.assign(new Error("ELOOP: too many levels of symbolic links"), { + code: "ELOOP", + }); + } + return { + writeFile: vi.fn(async (content: string) => { + fileStore.set(path, content); + }), + close: vi.fn(async () => {}), + }; + }), + readFile: vi.fn(async (path: string) => { + const value = fileStore.get(path); + if (value === undefined) throw new Error("ENOENT"); + return value; + }), + writeFile: vi.fn(async (path: string, content: string) => { + fileStore.set(path, content); + }), +})); + +import { registerCompressFileFunction } from "../src/functions/compress-file.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("mem::compress-file", () => { + let sdk: ReturnType; + let kv: ReturnType; + let summarize: ReturnType; + + beforeEach(() => { + fileStore.clear(); + symlinkPaths.clear(); + openEloopPaths.clear(); + sdk = mockSdk(); + kv = mockKV(); + summarize = vi.fn(); + registerCompressFileFunction( + sdk as never, + kv as never, + { name: "test-provider", summarize, compress: summarize } as never, + ); + }); + + it("rejects symlinks", async () => { + symlinkPaths.add("/tmp/notes.md"); + const result = (await sdk.trigger("mem::compress-file", { + filePath: "/tmp/notes.md", + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toContain("symlink"); + expect(summarize).not.toHaveBeenCalled(); + expect(fileStore.size).toBe(0); + }); + + it("rejects TOCTOU symlink swap at write time via O_NOFOLLOW", async () => { + const path = "/tmp/notes.md"; + fileStore.set( + path, + "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nContent.", + ); + summarize.mockResolvedValue( + "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nShort.", + ); + openEloopPaths.add(path); + + const result = (await sdk.trigger("mem::compress-file", { + filePath: path, + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toContain("symlink"); + }); + + it("rejects non-markdown paths", async () => { + const result = (await sdk.trigger("mem::compress-file", { + filePath: "/tmp/readme.txt", + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toContain(".md"); + }); + + it("returns file not found for missing paths", async () => { + const result = (await sdk.trigger("mem::compress-file", { + filePath: "/tmp/nonexistent.md", + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + }); + + it("compresses markdown and writes .original.md backup", async () => { + const path = "/tmp/notes.md"; + fileStore.set( + path, + "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nSome long explanation.", + ); + + summarize.mockResolvedValue( + "# Title\n\nVisit https://example.com\n\n```ts\nconst x = 1;\n```\n\nShort explanation.", + ); + + const result = (await sdk.trigger("mem::compress-file", { + filePath: path, + })) as { + success: boolean; + backupPath: string; + compressedChars: number; + originalChars: number; + }; + + expect(result.success).toBe(true); + expect(result.backupPath).toBe("/tmp/notes.original.md"); + expect(fileStore.get("/tmp/notes.original.md")).toContain("Some long explanation."); + expect(fileStore.get(path)).toContain("Short explanation."); + expect(result.compressedChars).toBeLessThan(result.originalChars); + }); + + it("fails validation when URLs change", async () => { + const path = "/tmp/guide.md"; + fileStore.set(path, "# Guide\n\nhttps://example.com\n"); + summarize.mockResolvedValue("# Guide\n\nhttps://different.example.com\n"); + + const result = (await sdk.trigger("mem::compress-file", { + filePath: path, + })) as { success: boolean; error: string; details: string[] }; + + expect(result.success).toBe(false); + expect(result.error).toContain("validation"); + expect(result.details.some((d) => d.includes("url"))).toBe(true); + expect(fileStore.get("/tmp/guide.original.md")).toBeUndefined(); + }); + + it("uses a distinct backup path for *.original.md inputs", async () => { + const path = "/tmp/notes.original.md"; + fileStore.set(path, "# Title\n\nLong original body."); + summarize.mockResolvedValue("# Title\n\nShort body."); + + const result = (await sdk.trigger("mem::compress-file", { + filePath: path, + })) as { success: boolean; backupPath: string }; + + expect(result.success).toBe(true); + expect(result.backupPath).toBe("/tmp/notes.original.backup.md"); + expect(fileStore.get("/tmp/notes.original.backup.md")).toBe( + "# Title\n\nLong original body.", + ); + expect(fileStore.get(path)).toBe("# Title\n\nShort body."); + }); +}); diff --git a/test/confidence.test.ts b/test/confidence.test.ts new file mode 100644 index 0000000..74f0a6f --- /dev/null +++ b/test/confidence.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerRelationsFunction } from "../src/functions/relations.js"; +import type { Memory, MemoryRelation } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeMemory(overrides: Partial = {}): Memory { + return { + id: "mem_1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "pattern", + title: "Test memory", + content: "This is a test memory", + concepts: ["test"], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + ...overrides, + }; +} + +describe("Confidence Scoring", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerRelationsFunction(sdk as never, kv as never); + }); + + it("computes default confidence from co-occurrence and recency", async () => { + const mem1 = makeMemory({ + id: "mem_1", + sessionIds: ["ses_1", "ses_2"], + updatedAt: new Date().toISOString(), + }); + const mem2 = makeMemory({ + id: "mem_2", + sessionIds: ["ses_1", "ses_2", "ses_3"], + updatedAt: new Date().toISOString(), + }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + + const result = (await sdk.trigger("mem::relate", { + sourceId: "mem_1", + targetId: "mem_2", + type: "related", + })) as { success: boolean; relation: MemoryRelation }; + + expect(result.success).toBe(true); + expect(result.relation.confidence).toBeGreaterThan(0.5); + expect(result.relation.confidence).toBeLessThanOrEqual(1); + }); + + it("uses explicit confidence when provided", async () => { + const mem1 = makeMemory({ id: "mem_1" }); + const mem2 = makeMemory({ id: "mem_2" }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + + const result = (await sdk.trigger("mem::relate", { + sourceId: "mem_1", + targetId: "mem_2", + type: "related", + confidence: 0.95, + })) as { success: boolean; relation: MemoryRelation }; + + expect(result.relation.confidence).toBe(0.95); + }); + + it("clamps confidence to [0, 1]", async () => { + const mem1 = makeMemory({ id: "mem_1" }); + const mem2 = makeMemory({ id: "mem_2" }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + + const over = (await sdk.trigger("mem::relate", { + sourceId: "mem_1", + targetId: "mem_2", + type: "related", + confidence: 1.5, + })) as { success: boolean; relation: MemoryRelation }; + expect(over.relation.confidence).toBe(1); + + const mem3 = makeMemory({ id: "mem_3" }); + await kv.set("mem:memories", "mem_3", mem3); + + const under = (await sdk.trigger("mem::relate", { + sourceId: "mem_1", + targetId: "mem_3", + type: "related", + confidence: -0.5, + })) as { success: boolean; relation: MemoryRelation }; + expect(under.relation.confidence).toBe(0); + }); + + it("mem::get-related sorts by confidence desc", async () => { + const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2", "mem_3"] }); + const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1"] }); + const mem3 = makeMemory({ id: "mem_3", relatedIds: ["mem_1"] }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + await kv.set("mem:memories", "mem_3", mem3); + + await kv.set("mem:relations", "rel_low", { + type: "related", + sourceId: "mem_1", + targetId: "mem_2", + createdAt: new Date().toISOString(), + confidence: 0.3, + } as MemoryRelation); + await kv.set("mem:relations", "rel_high", { + type: "related", + sourceId: "mem_1", + targetId: "mem_3", + createdAt: new Date().toISOString(), + confidence: 0.9, + } as MemoryRelation); + + const result = (await sdk.trigger("mem::get-related", { + memoryId: "mem_1", + maxHops: 1, + })) as { results: Array<{ memory: Memory; hop: number; confidence: number }> }; + + expect(result.results.length).toBe(2); + expect(result.results[0].confidence).toBeGreaterThanOrEqual( + result.results[1].confidence, + ); + }); + + it("minConfidence filter works", async () => { + const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2", "mem_3"] }); + const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1"] }); + const mem3 = makeMemory({ id: "mem_3", relatedIds: ["mem_1"] }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + await kv.set("mem:memories", "mem_3", mem3); + + await kv.set("mem:relations", "rel_low", { + type: "related", + sourceId: "mem_1", + targetId: "mem_2", + createdAt: new Date().toISOString(), + confidence: 0.2, + } as MemoryRelation); + await kv.set("mem:relations", "rel_high", { + type: "related", + sourceId: "mem_1", + targetId: "mem_3", + createdAt: new Date().toISOString(), + confidence: 0.8, + } as MemoryRelation); + + const result = (await sdk.trigger("mem::get-related", { + memoryId: "mem_1", + maxHops: 1, + minConfidence: 0.5, + })) as { results: Array<{ memory: Memory; hop: number; confidence: number }> }; + + expect(result.results.length).toBe(1); + expect(result.results[0].memory.id).toBe("mem_3"); + }); + + it("mem::evolve creates supersedes relation with confidence=1.0", async () => { + const original = makeMemory({ id: "mem_old", version: 1 }); + await kv.set("mem:memories", "mem_old", original); + + await sdk.trigger("mem::evolve", { + memoryId: "mem_old", + newContent: "Updated content", + }); + + const relations = await kv.list("mem:relations"); + const supersedesRel = relations.find((r) => r.type === "supersedes"); + expect(supersedesRel).toBeDefined(); + expect(supersedesRel!.confidence).toBe(1.0); + }); + + it("old relations without confidence default to 0.5", async () => { + const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2"] }); + const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1"] }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + + await kv.set("mem:relations", "rel_old", { + type: "related", + sourceId: "mem_1", + targetId: "mem_2", + createdAt: new Date().toISOString(), + } as MemoryRelation); + + const result = (await sdk.trigger("mem::get-related", { + memoryId: "mem_1", + maxHops: 1, + })) as { results: Array<{ memory: Memory; hop: number; confidence: number }> }; + + expect(result.results.length).toBe(1); + expect(result.results[0].confidence).toBe(0.5); + }); +}); diff --git a/test/connect-new-agents.test.ts b/test/connect-new-agents.test.ts new file mode 100644 index 0000000..9ac4485 --- /dev/null +++ b/test/connect-new-agents.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir, platform } from "node:os"; +import { join } from "node:path"; + +// Connect adapters for Qwen Code, Antigravity, and Kiro. Each writes +// the canonical MCP block (npx @agentmemory/mcp + env defaults) into +// the agent's documented config path. + +function freshHome(): string { + return mkdtempSync(join(tmpdir(), "am-connect-")); +} + +describe("connect: Qwen Code", () => { + let home: string; + const ORIG = process.env["HOME"]; + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + }); + afterEach(() => { + process.env["HOME"] = ORIG; + rmSync(home, { recursive: true, force: true }); + }); + + it("does not detect when ~/.qwen/ is absent", async () => { + const { adapter } = await import("../src/cli/connect/qwen.js"); + expect(adapter.detect()).toBe(false); + }); + + it("writes mcpServers.agentmemory to ~/.qwen/settings.json", async () => { + mkdirSync(join(home, ".qwen"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/qwen.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfg = JSON.parse( + readFileSync(join(home, ".qwen", "settings.json"), "utf-8"), + ); + expect(cfg.mcpServers.agentmemory.command).toBe("npx"); + expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp"); + expect(cfg.mcpServers.agentmemory.env.AGENTMEMORY_URL).toMatch( + /\$\{AGENTMEMORY_URL:-/, + ); + expect(cfg.mcpServers.agentmemory.env.AGENTMEMORY_TOOLS).toMatch( + /\$\{AGENTMEMORY_TOOLS:-all\}/, + ); + }); +}); + +describe("connect: Antigravity", () => { + let home: string; + const ORIG = process.env["HOME"]; + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + }); + afterEach(() => { + process.env["HOME"] = ORIG; + rmSync(home, { recursive: true, force: true }); + }); + + it("writes mcpServers.agentmemory to the platform-specific config path", async () => { + const isMac = platform() === "darwin"; + const userDir = isMac + ? join(home, "Library", "Application Support", "Antigravity", "User") + : join(home, ".config", "Antigravity", "User"); + mkdirSync(userDir, { recursive: true }); + const { adapter } = await import("../src/cli/connect/antigravity.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfg = JSON.parse( + readFileSync(join(userDir, "mcp_config.json"), "utf-8"), + ); + expect(cfg.mcpServers.agentmemory.command).toBe("npx"); + expect(cfg.mcpServers.agentmemory.env.AGENTMEMORY_URL).toMatch( + /\$\{AGENTMEMORY_URL:-/, + ); + }); +}); + +describe("connect: Kiro", () => { + let home: string; + const ORIG = process.env["HOME"]; + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + }); + afterEach(() => { + process.env["HOME"] = ORIG; + rmSync(home, { recursive: true, force: true }); + }); + + it("does not detect when ~/.kiro/ is absent", async () => { + const { adapter } = await import("../src/cli/connect/kiro.js"); + expect(adapter.detect()).toBe(false); + }); + + it("writes mcpServers.agentmemory to ~/.kiro/settings/mcp.json", async () => { + mkdirSync(join(home, ".kiro"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/kiro.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfgPath = join(home, ".kiro", "settings", "mcp.json"); + expect(existsSync(cfgPath)).toBe(true); + const cfg = JSON.parse(readFileSync(cfgPath, "utf-8")); + expect(cfg.mcpServers.agentmemory.command).toBe("npx"); + expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp"); + }); +}); + +describe("connect: Warp", () => { + let home: string; + const ORIG = process.env["HOME"]; + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + }); + afterEach(() => { + process.env["HOME"] = ORIG; + rmSync(home, { recursive: true, force: true }); + }); + + it("does not detect when ~/.warp/ is absent", async () => { + const { adapter } = await import("../src/cli/connect/warp.js"); + expect(adapter.detect()).toBe(false); + }); + + it("writes mcpServers.agentmemory to ~/.warp/.mcp.json", async () => { + mkdirSync(join(home, ".warp"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/warp.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfgPath = join(home, ".warp", ".mcp.json"); + expect(existsSync(cfgPath)).toBe(true); + const cfg = JSON.parse(readFileSync(cfgPath, "utf-8")); + expect(cfg.mcpServers.agentmemory.command).toBe("npx"); + expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp"); + expect(cfg.mcpServers.agentmemory.env.AGENTMEMORY_URL).toMatch( + /\$\{AGENTMEMORY_URL:-/, + ); + }); +}); + +describe("connect: Cline", () => { + let home: string; + const ORIG = process.env["HOME"]; + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + }); + afterEach(() => { + process.env["HOME"] = ORIG; + rmSync(home, { recursive: true, force: true }); + }); + + it("does not detect when ~/.cline/ is absent", async () => { + const { adapter } = await import("../src/cli/connect/cline.js"); + expect(adapter.detect()).toBe(false); + }); + + it("writes mcpServers.agentmemory to ~/.cline/mcp.json", async () => { + mkdirSync(join(home, ".cline"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/cline.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfg = JSON.parse( + readFileSync(join(home, ".cline", "mcp.json"), "utf-8"), + ); + expect(cfg.mcpServers.agentmemory.command).toBe("npx"); + expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp"); + }); +}); + +describe("connect: Droid (Factory.ai)", () => { + let home: string; + const ORIG = process.env["HOME"]; + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + }); + afterEach(() => { + process.env["HOME"] = ORIG; + rmSync(home, { recursive: true, force: true }); + }); + + it("does not detect when ~/.factory/ is absent", async () => { + const { adapter } = await import("../src/cli/connect/droid.js"); + expect(adapter.detect()).toBe(false); + }); + + it("writes mcpServers.agentmemory to ~/.factory/mcp.json with type:stdio", async () => { + mkdirSync(join(home, ".factory"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/droid.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfg = JSON.parse( + readFileSync(join(home, ".factory", "mcp.json"), "utf-8"), + ); + expect(cfg.mcpServers.agentmemory.command).toBe("npx"); + expect(cfg.mcpServers.agentmemory.args).toContain("@agentmemory/mcp"); + // Droid requires `type` per its documented schema + expect(cfg.mcpServers.agentmemory.type).toBe("stdio"); + }); +}); + +describe("connect: Zed", () => { + let home: string; + const ORIG = process.env["HOME"]; + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + }); + afterEach(() => { + process.env["HOME"] = ORIG; + rmSync(home, { recursive: true, force: true }); + }); + + it("does not detect when ~/.config/zed/ is absent", async () => { + const { adapter } = await import("../src/cli/connect/zed.js"); + expect(adapter.detect()).toBe(false); + }); + + it("writes context_servers.agentmemory to ~/.config/zed/settings.json", async () => { + mkdirSync(join(home, ".config", "zed"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/zed.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfg = JSON.parse( + readFileSync(join(home, ".config", "zed", "settings.json"), "utf-8"), + ); + expect(cfg.context_servers.agentmemory.command).toBe("npx"); + expect(cfg.context_servers.agentmemory.args).toContain("@agentmemory/mcp"); + expect(cfg.mcpServers).toBeUndefined(); + }); +}); + +describe("connect: Continue.dev", () => { + let home: string; + const ORIG = process.env["HOME"]; + beforeEach(() => { + home = freshHome(); + vi.resetModules(); + process.env["HOME"] = home; + }); + afterEach(() => { + process.env["HOME"] = ORIG; + rmSync(home, { recursive: true, force: true }); + }); + + it("does not detect when ~/.continue/ is absent", async () => { + const { adapter } = await import("../src/cli/connect/continue.js"); + expect(adapter.detect()).toBe(false); + }); + + it("creates config.yaml from scratch when neither yaml nor json exists", async () => { + mkdirSync(join(home, ".continue"), { recursive: true }); + const { adapter } = await import("../src/cli/connect/continue.js"); + expect(adapter.detect()).toBe(true); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const yamlPath = join(home, ".continue", "config.yaml"); + expect(existsSync(yamlPath)).toBe(true); + expect(existsSync(join(home, ".continue", "config.json"))).toBe(false); + const yaml = readFileSync(yamlPath, "utf-8"); + expect(yaml).toContain("mcpServers:"); + expect(yaml).toContain("name: agentmemory"); + expect(yaml).toContain("@agentmemory/mcp"); + expect(yaml).toContain("AGENTMEMORY_URL"); + }); + + it("modifies existing legacy config.json", async () => { + mkdirSync(join(home, ".continue"), { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync( + join(home, ".continue", "config.json"), + JSON.stringify({ models: [], mcpServers: [] }), + ); + const { adapter } = await import("../src/cli/connect/continue.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("installed"); + const cfg = JSON.parse( + readFileSync(join(home, ".continue", "config.json"), "utf-8"), + ); + expect(Array.isArray(cfg.mcpServers)).toBe(true); + const entry = cfg.mcpServers.find( + (s: { name: string }) => s.name === "agentmemory", + ); + expect(entry.command).toBe("npx"); + expect(entry.args).toContain("@agentmemory/mcp"); + }); + + it("returns stub when config.yaml already exists (refuses silent yaml mutation)", async () => { + mkdirSync(join(home, ".continue"), { recursive: true }); + const { writeFileSync } = await import("node:fs"); + writeFileSync( + join(home, ".continue", "config.yaml"), + "models: []\nmcpServers:\n - name: existing\n command: noop\n", + ); + const { adapter } = await import("../src/cli/connect/continue.js"); + const result = await adapter.install({ dryRun: false, force: false }); + expect(result.kind).toBe("stub"); + // user's yaml must be untouched + const yaml = readFileSync( + join(home, ".continue", "config.yaml"), + "utf-8", + ); + expect(yaml).toContain("existing"); + expect(yaml).not.toContain("agentmemory"); + }); +}); + +describe("connect: all eight new agents registered in ADAPTERS", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("knownAgents includes qwen, antigravity, kiro, warp, cline, continue, zed, droid", async () => { + const { knownAgents } = await import("../src/cli/connect/index.js"); + const agents = knownAgents(); + for (const name of [ + "qwen", + "antigravity", + "kiro", + "warp", + "cline", + "continue", + "zed", + "droid", + ]) { + expect(agents).toContain(name); + } + }); +}); diff --git a/test/consistency.test.ts b/test/consistency.test.ts new file mode 100644 index 0000000..e0871cb --- /dev/null +++ b/test/consistency.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { getAllTools } from "../src/mcp/tools-registry.js"; +import { VERSION } from "../src/version.js"; + +const ROOT = join(import.meta.dirname, ".."); + +function readText(relativePath: string): string { + return readFileSync(join(ROOT, relativePath), "utf-8"); +} + +function countRestApiEndpoints(): number { + const src = readText("src/triggers/api.ts"); + return Array.from(src.matchAll(/api_path:\s*["`]/g)).length; +} + +describe("Consistency checks", () => { + const toolCount = getAllTools().length; + const restEndpointCount = countRestApiEndpoints(); + + it("version.ts matches package.json", () => { + const pkg = JSON.parse(readText("package.json")); + expect(VERSION).toBe(pkg.version); + }); + + it("plugin.json version matches package.json", () => { + const pkg = JSON.parse(readText("package.json")); + const plugin = JSON.parse(readText("plugin/.claude-plugin/plugin.json")); + expect(plugin.version).toBe(pkg.version); + }); + + it("export-import.ts supports current version", () => { + const src = readText("src/functions/export-import.ts"); + expect(src).toContain(`"${VERSION}"`); + }); + + it("README mentions correct MCP tool count", () => { + const readme = readText("README.md"); + const toolCountPattern = new RegExp(`${toolCount}\\s+MCP tools`); + expect(readme).toMatch(toolCountPattern); + const toolResourcePattern = new RegExp(`${toolCount}\\s+tools,\\s+6\\s+resources`); + expect(readme).toMatch(toolResourcePattern); + }); + + it("documented REST endpoint counts match registered API paths", () => { + const readme = readText("README.md"); + const agents = readText("AGENTS.md"); + const index = readText("src/index.ts"); + + expect(restEndpointCount).toBeGreaterThan(0); + expect(readme).toContain(`${restEndpointCount} endpoints on port`); + expect(agents).toContain(`${restEndpointCount} REST endpoints`); + expect(index).toContain(`REST API: ${restEndpointCount} endpoints`); + }); + + it("all tool names are unique", () => { + const tools = getAllTools(); + const names = new Set(tools.map((t) => t.name)); + expect(names.size).toBe(tools.length); + }); + + it("all tools have name, description, and inputSchema", () => { + for (const tool of getAllTools()) { + expect(tool.name).toBeTruthy(); + expect(tool.description).toBeTruthy(); + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema.type).toBe("object"); + } + }); + + it("every host-path bind mount in docker-compose.yml is in the published files list (#136)", () => { + // Regression guard for #136: docker-compose.yml references + // ./iii-config.docker.yaml as a read-only bind mount, but the file + // was missing from the published tarball. Docker silently creates + // missing bind sources as empty directories, so the engine crashed + // with "Is a directory (os error 21)" at /app/config.yaml. + const compose = readText("docker-compose.yml"); + const pkg = JSON.parse(readText("package.json")); + const files: string[] = pkg.files ?? []; + + // Match `./:` style bind mounts. We only care + // about files that live in the repo root (so they'd be shipped via + // the `files` field). `iii-data:/data` (a named volume) has no `./` + // prefix and is correctly skipped. + const bindRe = /^\s*-\s+\.\/([^\s:]+):[^\s]+/gm; + const sources: string[] = []; + for (const m of compose.matchAll(bindRe)) sources.push(m[1]!); + + expect(sources.length).toBeGreaterThan(0); + for (const src of sources) { + // Any nested path would need a directory entry in `files` (e.g. + // `dist/`); for top-level files, the exact name must be listed. + const topLevel = src.split("/")[0]!; + const covered = + files.includes(src) || + files.includes(topLevel) || + files.includes(`${topLevel}/`); + expect( + covered, + `docker-compose.yml mounts ./${src} but package.json "files" does not ship it — ${topLevel} would be auto-created as an empty dir on install, breaking \`npx @agentmemory/agentmemory\``, + ).toBe(true); + } + }); +}); diff --git a/test/consolidate-project-scope.test.ts b/test/consolidate-project-scope.test.ts new file mode 100644 index 0000000..e8efc45 --- /dev/null +++ b/test/consolidate-project-scope.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/functions/audit.js", () => ({ + recordAudit: vi.fn(), +})); + +import { registerConsolidateFunction } from "../src/functions/consolidate.js"; +import { KV } from "../src/state/schema.js"; +import type { CompressedObservation, Memory, MemoryProvider, Session } from "../src/types.js"; + +function makeMockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeMockSdk() { + const functions = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function registered: ${id}`); + return fn(payload); + }, + }; +} + +function makeProvider(title = "synthesized memory title"): MemoryProvider { + return { + name: "mock", + compress: vi.fn().mockResolvedValue( + ` + pattern + ${title} + synthesized content about the concept + auth + src/auth.ts + 7 + `, + ), + embed: vi.fn().mockResolvedValue(new Float32Array(384)), + embedBatch: vi.fn().mockResolvedValue([]), + dimensions: 384, + compressionModel: "mock-model", + }; +} + +function makeSession(id: string, project: string): Session { + return { + id, + project, + cwd: `/srv/${project}`, + startedAt: new Date().toISOString(), + status: "completed", + observationCount: 5, + }; +} + +function makeObs(id: string, sessionId: string, concept: string): CompressedObservation { + return { + id, + sessionId, + timestamp: new Date().toISOString(), + type: "decision", + title: `${concept} observation ${id}`, + facts: [`fact about ${concept}`], + narrative: `detailed narrative about ${concept} pattern usage`, + concepts: [concept], + files: ["src/auth.ts"], + importance: 8, + }; +} + +function makeExistingMemory(id: string, title: string, project?: string): Memory { + return { + id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "pattern", + title, + content: "existing content", + concepts: ["auth"], + files: ["src/auth.ts"], + sessionIds: [], + strength: 6, + version: 1, + isLatest: true, + ...(project !== undefined && { project }), + }; +} + +describe("mem::consolidate — cross-project existingMatch guard", () => { + it("does not evolve a memory from a different project even when titles match", async () => { + const sdk = makeMockSdk(); + const kv = makeMockKV(); + const provider = makeProvider("synthesized memory title"); + + // A memory scoped to "web" with the same title the provider will generate + const webMemory = makeExistingMemory("mem_web", "synthesized memory title", "web"); + await kv.set(KV.memories, webMemory.id, webMemory); + + // Session and observations for "api" project + const apiSession = makeSession("sess_api", "api"); + await kv.set(KV.sessions, apiSession.id, apiSession); + for (let i = 0; i < 3; i++) { + await kv.set( + KV.observations(apiSession.id), + `obs_${i}`, + makeObs(`obs_${i}`, apiSession.id, "auth"), + ); + } + + registerConsolidateFunction(sdk as never, kv as never, provider as never); + await sdk.trigger("mem::consolidate", { project: "api", minObservations: 1 }); + + // The web memory must remain untouched — isLatest still true + const webStored = await kv.get(KV.memories, webMemory.id); + expect(webStored?.isLatest).toBe(true); + expect(webStored?.project).toBe("web"); + + // A new "api" memory should have been created + const allMemories = await kv.list(KV.memories); + const apiMemories = allMemories.filter((m) => m.project === "api" && m.isLatest); + expect(apiMemories).toHaveLength(1); + expect(apiMemories[0].title).toBe("synthesized memory title"); + }); + + it("evolves an existing memory within the same project when titles match", async () => { + const sdk = makeMockSdk(); + const kv = makeMockKV(); + const provider = makeProvider("synthesized memory title"); + + // A memory already scoped to "api" with the same title + const apiMemory = makeExistingMemory("mem_api_old", "synthesized memory title", "api"); + await kv.set(KV.memories, apiMemory.id, apiMemory); + + const apiSession = makeSession("sess_api", "api"); + await kv.set(KV.sessions, apiSession.id, apiSession); + for (let i = 0; i < 3; i++) { + await kv.set( + KV.observations(apiSession.id), + `obs_${i}`, + makeObs(`obs_${i}`, apiSession.id, "auth"), + ); + } + + registerConsolidateFunction(sdk as never, kv as never, provider as never); + await sdk.trigger("mem::consolidate", { project: "api", minObservations: 1 }); + + // The old api memory should have been marked non-latest (evolved) + const oldMemory = await kv.get(KV.memories, apiMemory.id); + expect(oldMemory?.isLatest).toBe(false); + + // A new evolved memory should exist + const allMemories = await kv.list(KV.memories); + const latestApi = allMemories.filter((m) => m.project === "api" && m.isLatest); + expect(latestApi).toHaveLength(1); + expect(latestApi[0].id).not.toBe(apiMemory.id); + expect(latestApi[0].parentId).toBe(apiMemory.id); + }); + + it("unscoped consolidation may evolve any existing memory regardless of project (background cron behavior)", async () => { + const sdk = makeMockSdk(); + const kv = makeMockKV(); + const provider = makeProvider("synthesized memory title"); + + // A scoped memory that an unscoped consolidation run should be able to evolve + const scopedMemory = makeExistingMemory("mem_api_old", "synthesized memory title", "api"); + await kv.set(KV.memories, scopedMemory.id, scopedMemory); + + // Session with no project restriction — unscoped consolidation + const session = makeSession("sess_any", "any"); + await kv.set(KV.sessions, session.id, session); + for (let i = 0; i < 3; i++) { + await kv.set( + KV.observations(session.id), + `obs_${i}`, + makeObs(`obs_${i}`, session.id, "auth"), + ); + } + + registerConsolidateFunction(sdk as never, kv as never, provider as never); + // No project passed — unscoped consolidation + await sdk.trigger("mem::consolidate", { minObservations: 1 }); + + // The existing scoped memory should have been evolved (unscoped run is unrestricted) + const old = await kv.get(KV.memories, scopedMemory.id); + expect(old?.isLatest).toBe(false); + + // The evolved successor should be latest and carry no project (unscoped run) + const allMemories = await kv.list(KV.memories); + const successor = allMemories.find((m) => m.isLatest && m.id !== scopedMemory.id); + expect(successor).toBeDefined(); + expect(successor?.isLatest).toBe(true); + expect(successor?.project).toBeUndefined(); + }); + + it("stamps the correct project on newly created memories", async () => { + const sdk = makeMockSdk(); + const kv = makeMockKV(); + const provider = makeProvider("brand new memory"); + + const session = makeSession("sess_api", "api"); + await kv.set(KV.sessions, session.id, session); + for (let i = 0; i < 3; i++) { + await kv.set( + KV.observations(session.id), + `obs_${i}`, + makeObs(`obs_${i}`, session.id, "auth"), + ); + } + + registerConsolidateFunction(sdk as never, kv as never, provider as never); + await sdk.trigger("mem::consolidate", { project: "api", minObservations: 1 }); + + const memories = await kv.list(KV.memories); + expect(memories).toHaveLength(1); + expect(memories[0].project).toBe("api"); + }); + + it("leaves project undefined on memories when consolidate is called without a project", async () => { + const sdk = makeMockSdk(); + const kv = makeMockKV(); + const provider = makeProvider("unscoped memory"); + + const session = makeSession("sess_any", "any"); + await kv.set(KV.sessions, session.id, session); + for (let i = 0; i < 3; i++) { + await kv.set( + KV.observations(session.id), + `obs_${i}`, + makeObs(`obs_${i}`, session.id, "auth"), + ); + } + + registerConsolidateFunction(sdk as never, kv as never, provider as never); + await sdk.trigger("mem::consolidate", { minObservations: 1 }); + + const memories = await kv.list(KV.memories); + expect(memories).toHaveLength(1); + expect(memories[0].project).toBeUndefined(); + }); +}); diff --git a/test/consolidation-default.test.ts b/test/consolidation-default.test.ts new file mode 100644 index 0000000..d7a7d5d --- /dev/null +++ b/test/consolidation-default.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ENV_KEYS = [ + "CONSOLIDATION_ENABLED", + "AGENTMEMORY_PROVIDER", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENAI_API_KEY_FOR_LLM", + "OPENROUTER_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "MINIMAX_API_KEY", + "OPENAI_BASE_URL", +]; + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; +const ORIGINAL: Record = {}; + +let sandboxHome: string; + +async function freshConfig() { + vi.resetModules(); + return await import("../src/config.js"); +} + +function writeEnv(contents: string) { + const dir = join(sandboxHome, ".agentmemory"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".env"), contents); +} + +describe("isConsolidationEnabled default behavior", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-consolidation-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + for (const k of ENV_KEYS) { + ORIGINAL[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + if (ORIGINAL_HOME === undefined) delete process.env["HOME"]; + else process.env["HOME"] = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE; + for (const k of ENV_KEYS) { + if (ORIGINAL[k] === undefined) delete process.env[k]; + else process.env[k] = ORIGINAL[k]; + } + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + it("returns false when no LLM provider configured (default BM25-only mode)", async () => { + writeEnv(""); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(false); + }); + + it("returns true by default when ANTHROPIC_API_KEY is set", async () => { + writeEnv("ANTHROPIC_API_KEY=sk-test-123"); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(true); + }); + + it("returns true by default when OPENAI_API_KEY is set", async () => { + writeEnv("OPENAI_API_KEY=sk-test-123"); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(true); + }); + + it("returns true by default when OPENAI_BASE_URL is set (local OpenAI-compatible)", async () => { + writeEnv("OPENAI_BASE_URL=http://localhost:1234/v1"); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(true); + }); + + it("returns true by default when AGENTMEMORY_PROVIDER=agent-sdk", async () => { + writeEnv("AGENTMEMORY_PROVIDER=agent-sdk"); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(true); + }); + + it("explicit CONSOLIDATION_ENABLED=false overrides provider-based default", async () => { + writeEnv("ANTHROPIC_API_KEY=sk-test-123\nCONSOLIDATION_ENABLED=false"); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(false); + }); + + it("explicit CONSOLIDATION_ENABLED=true overrides absence of provider", async () => { + writeEnv("CONSOLIDATION_ENABLED=true"); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(true); + }); + + it("AGENTMEMORY_PROVIDER=noop returns false even with API key set", async () => { + writeEnv("AGENTMEMORY_PROVIDER=noop\nANTHROPIC_API_KEY=sk-test-123"); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(false); + }); + + it("OPENAI_API_KEY_FOR_LLM=false scopes the key to embeddings only", async () => { + writeEnv("OPENAI_API_KEY=sk-test-123\nOPENAI_API_KEY_FOR_LLM=false"); + const cfg = await freshConfig(); + expect(cfg.isConsolidationEnabled()).toBe(false); + }); +}); diff --git a/test/consolidation-pipeline.test.ts b/test/consolidation-pipeline.test.ts new file mode 100644 index 0000000..5f72090 --- /dev/null +++ b/test/consolidation-pipeline.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/config.js", () => ({ + getConsolidationDecayDays: () => 30, + isConsolidationEnabled: vi.fn(() => true), +})); + +import { registerConsolidationPipelineFunction } from "../src/functions/consolidation-pipeline.js"; +import { isConsolidationEnabled } from "../src/config.js"; +import type { SessionSummary, Memory, SemanticMemory, ProceduralMemory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeSummary(i: number): SessionSummary { + return { + sessionId: `ses_${i}`, + project: "test-project", + createdAt: new Date(Date.now() - i * 86400000).toISOString(), + title: `Session ${i} summary`, + narrative: `Worked on feature ${i}`, + keyDecisions: [`Decision ${i}`], + filesModified: [`src/file${i}.ts`], + concepts: ["typescript", "testing"], + observationCount: 5, + }; +} + +function makePattern(i: number): Memory { + return { + id: `mem_${i}`, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + type: "pattern", + title: `Pattern ${i}`, + content: `Always do thing ${i}`, + concepts: ["testing"], + files: [], + sessionIds: ["ses_1", "ses_2"], + strength: 5, + version: 1, + isLatest: true, + }; +} + +describe("Consolidation Pipeline", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + }); + + it("pipeline skips semantic when fewer than 5 summaries", async () => { + const provider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), + }; + registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never); + + for (let i = 0; i < 3; i++) { + await kv.set("mem:summaries", `ses_${i}`, makeSummary(i)); + } + + const result = (await sdk.trigger("mem::consolidate-pipeline", { + tier: "semantic", + })) as { success: boolean; results: Record }; + + expect(result.success).toBe(true); + const semantic = result.results.semantic as { skipped: boolean; reason: string }; + expect(semantic.skipped).toBe(true); + expect(semantic.reason).toContain("fewer than 5"); + expect(provider.summarize).not.toHaveBeenCalled(); + }); + + it("pipeline skips procedural when fewer than 2 patterns", async () => { + const provider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), + }; + registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never); + + const mem: Memory = { + ...makePattern(1), + sessionIds: ["ses_1", "ses_2"], + }; + await kv.set("mem:memories", "mem_1", mem); + + const result = (await sdk.trigger("mem::consolidate-pipeline", { + tier: "procedural", + })) as { success: boolean; results: Record }; + + expect(result.success).toBe(true); + const procedural = result.results.procedural as { skipped: boolean; reason: string }; + expect(procedural.skipped).toBe(true); + expect(procedural.reason).toContain("fewer than 2"); + }); + + it("with enough summaries, creates semantic memories from provider response", async () => { + const provider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn().mockResolvedValue( + `TypeScript is the primary language`, + ), + }; + registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never); + + for (let i = 0; i < 6; i++) { + await kv.set("mem:summaries", `ses_${i}`, makeSummary(i)); + } + + const result = (await sdk.trigger("mem::consolidate-pipeline", { + tier: "semantic", + })) as { success: boolean; results: Record }; + + expect(result.success).toBe(true); + const semantic = result.results.semantic as { newFacts: number }; + expect(semantic.newFacts).toBe(1); + + const stored = await kv.list("mem:semantic"); + expect(stored.length).toBe(1); + expect(stored[0].fact).toBe("TypeScript is the primary language"); + expect(stored[0].confidence).toBe(0.9); + }); + + it("with enough patterns, creates procedural memories from provider response", async () => { + const provider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn().mockResolvedValue( + `Create test fileWrite assertions`, + ), + }; + registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never); + + for (let i = 0; i < 3; i++) { + await kv.set("mem:memories", `mem_${i}`, makePattern(i)); + } + + const result = (await sdk.trigger("mem::consolidate-pipeline", { + tier: "procedural", + })) as { success: boolean; results: Record }; + + expect(result.success).toBe(true); + const procedural = result.results.procedural as { newProcedures: number }; + expect(procedural.newProcedures).toBe(1); + + const stored = await kv.list("mem:procedural"); + expect(stored.length).toBe(1); + expect(stored[0].name).toBe("Test Workflow"); + expect(stored[0].steps.length).toBe(2); + expect(stored[0].triggerCondition).toBe("when writing tests"); + }); + + it("consolidation records an audit entry", async () => { + const provider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), + }; + registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never); + + await sdk.trigger("mem::consolidate-pipeline", { tier: "semantic" }); + + const audits = await kv.list("mem:audit"); + expect(audits.length).toBe(1); + }); + + it("pipeline returns early when consolidation is disabled", async () => { + vi.mocked(isConsolidationEnabled).mockReturnValue(false); + const provider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), + }; + registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never); + + const result = (await sdk.trigger("mem::consolidate-pipeline", {})) as { + success: boolean; + skipped?: boolean; + reason?: string; + }; + + expect(result.success).toBe(false); + expect(result.skipped).toBe(true); + expect(result.reason).toContain("CONSOLIDATION_ENABLED"); + expect(provider.summarize).not.toHaveBeenCalled(); + vi.mocked(isConsolidationEnabled).mockReturnValue(true); + }); + + it("pipeline proceeds with force=true even when consolidation is disabled", async () => { + vi.mocked(isConsolidationEnabled).mockReturnValue(false); + const provider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), + }; + registerConsolidationPipelineFunction(sdk as never, kv as never, provider as never); + + const result = (await sdk.trigger("mem::consolidate-pipeline", { + force: true, + })) as { success: boolean; results: Record }; + + expect(result.success).toBe(true); + expect(result.results).toBeDefined(); + vi.mocked(isConsolidationEnabled).mockReturnValue(true); + }); +}); diff --git a/test/context-injection.test.ts b/test/context-injection.test.ts new file mode 100644 index 0000000..d768963 --- /dev/null +++ b/test/context-injection.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import { join } from "node:path"; + +const HOOKS_DIR = join(import.meta.dirname, "..", "plugin", "scripts"); + +// Spawns a compiled plugin hook as a subprocess, feeds it JSON on stdin, +// and returns { stdout, stderr, exitCode, tookMs }. The test is about +// making sure the hook writes NOTHING to stdout when context injection is +// disabled — which is what Claude Code reads to decide whether to prepend +// memory context to the next tool turn. +function runHook( + scriptName: string, + stdin: string, + env: Record, +): Promise<{ + stdout: string; + stderr: string; + exitCode: number | null; + tookMs: number; +}> { + return new Promise((resolve, reject) => { + const start = Date.now(); + const child = spawn( + process.execPath, + [join(HOOKS_DIR, scriptName)], + { + env: { + // Start from a clean slate — don't leak test-runner env into + // the hook. Only pass PATH and anything explicitly set by the + // test case. + PATH: process.env["PATH"] ?? "", + ...env, + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (exitCode) => { + resolve({ stdout, stderr, exitCode, tookMs: Date.now() - start }); + }); + + child.stdin.write(stdin); + child.stdin.end(); + }); +} + +describe("pre-tool-use hook — context injection gate (#143)", () => { + it("writes nothing to stdout when AGENTMEMORY_INJECT_CONTEXT is unset (default)", async () => { + const payload = JSON.stringify({ + session_id: "ses_test", + tool_name: "Read", + tool_input: { file_path: "src/foo.ts" }, + }); + // No AGENTMEMORY_* env vars at all — simulates a fresh Claude Pro + // install with no ~/.agentmemory/.env overrides. + const result = await runHook("pre-tool-use.mjs", payload, {}); + expect(result.stdout).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("writes nothing to stdout when AGENTMEMORY_INJECT_CONTEXT=false explicitly", async () => { + const payload = JSON.stringify({ + session_id: "ses_test", + tool_name: "Edit", + tool_input: { file_path: "src/foo.ts", old_string: "a", new_string: "b" }, + }); + const result = await runHook("pre-tool-use.mjs", payload, { + AGENTMEMORY_INJECT_CONTEXT: "false", + }); + expect(result.stdout).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("exits fast when disabled (no stdin consumption, no network fetch)", async () => { + // The disabled path must not open stdin or reach for fetch — it + // should return immediately. A 250ms budget is generous enough to + // account for Node startup on CI while still catching any accidental + // fetch round-trip or stdin buffering. + const result = await runHook("pre-tool-use.mjs", "", {}); + expect(result.tookMs).toBeLessThan(1000); + expect(result.stdout).toBe(""); + }); + + it("when AGENTMEMORY_INJECT_CONTEXT=true, hook still runs but safely errors on unreachable backend", async () => { + // Opt-in path. We point at a port that's guaranteed closed so the + // fetch fails fast; the hook must still exit cleanly (the whole + // point of the try/catch is not to break Claude Code) and must not + // echo anything to stdout when the fetch fails. + const payload = JSON.stringify({ + session_id: "ses_test", + tool_name: "Read", + tool_input: { file_path: "src/foo.ts" }, + }); + const result = await runHook("pre-tool-use.mjs", payload, { + AGENTMEMORY_INJECT_CONTEXT: "true", + AGENTMEMORY_URL: "http://127.0.0.1:1", + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + }); +}); + +describe("session-start hook — context injection gate (#143)", () => { + it("registers the session but writes nothing to stdout when AGENTMEMORY_INJECT_CONTEXT is unset", async () => { + // Session registration POST will fail against the unreachable URL, + // but the hook's try/catch must swallow that cleanly — Claude Code + // must never see an error at session start. + const payload = JSON.stringify({ + session_id: "ses_test", + cwd: "/tmp/fake-project", + }); + const result = await runHook("session-start.mjs", payload, { + AGENTMEMORY_URL: "http://127.0.0.1:1", + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + }); +}); diff --git a/test/context-lessons.test.ts b/test/context-lessons.test.ts new file mode 100644 index 0000000..d915c93 --- /dev/null +++ b/test/context-lessons.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { registerContextFunction } from "../src/functions/context.js"; +import { KV } from "../src/state/schema.js"; +import type { Lesson } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + if (!store.has(scope)) return []; + return Array.from(store.get(scope)!.values()) as T[]; + }, + }; +} + +type ContextHandler = (data: { + sessionId: string; + project: string; + budget?: number; +}) => Promise<{ context: string; blocks: number; tokens: number }>; + +function wireContext(kv: ReturnType, budget = 4000) { + let handler: ContextHandler | undefined; + const sdk = { + registerFunction: vi.fn((id: string, cb: ContextHandler) => { + if (id === "mem::context") handler = cb; + }), + } as unknown as import("iii-sdk").ISdk; + registerContextFunction(sdk, kv as never, budget); + if (!handler) throw new Error("mem::context not registered"); + return handler; +} + +function makeLesson(over: Partial = {}): Lesson { + const now = new Date().toISOString(); + return { + id: over.id ?? `lesson_${Math.random().toString(36).slice(2)}`, + content: over.content ?? "default lesson content", + context: over.context ?? "", + confidence: over.confidence ?? 0.7, + reinforcements: over.reinforcements ?? 1, + source: over.source ?? "manual", + sourceIds: over.sourceIds ?? [], + project: over.project, + tags: over.tags ?? [], + createdAt: over.createdAt ?? now, + updatedAt: over.updatedAt ?? now, + lastReinforcedAt: over.lastReinforcedAt, + lastDecayedAt: over.lastDecayedAt, + decayRate: over.decayRate ?? 0.05, + deleted: over.deleted, + }; +} + +async function seedLesson( + kv: ReturnType, + partial: Partial, +) { + const lesson = makeLesson(partial); + await kv.set(KV.lessons, lesson.id, lesson); + return lesson; +} + +describe("mem::context — lessons auto-injection (#457)", () => { + let kv: ReturnType; + let handler: ContextHandler; + + beforeEach(() => { + kv = mockKV(); + handler = wireContext(kv); + }); + + it("includes a 'Lessons Learned' block when KV has lessons for the project", async () => { + await seedLesson(kv, { + id: "lesson_a", + content: "always run npm test before commit", + project: "/tmp/proj", + confidence: 0.85, + }); + + const result = await handler({ + sessionId: "ses_a", + project: "/tmp/proj", + }); + + expect(result.context).toContain("Lessons Learned"); + expect(result.context).toContain("always run npm test before commit"); + expect(result.blocks).toBeGreaterThan(0); + }); + + it("omits the lessons block entirely when KV has no lessons", async () => { + const result = await handler({ + sessionId: "ses_empty", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("Lessons Learned"); + }); + + it("ranks project-scoped lessons above global lessons", async () => { + await seedLesson(kv, { + id: "lesson_global", + content: "global-lesson-marker", + project: undefined, + confidence: 0.9, + }); + await seedLesson(kv, { + id: "lesson_project", + content: "project-lesson-marker", + project: "/tmp/proj", + confidence: 0.7, + }); + + const result = await handler({ + sessionId: "ses_rank", + project: "/tmp/proj", + }); + + const projectIdx = result.context.indexOf("project-lesson-marker"); + const globalIdx = result.context.indexOf("global-lesson-marker"); + expect(projectIdx).toBeGreaterThan(-1); + expect(globalIdx).toBeGreaterThan(-1); + expect(projectIdx).toBeLessThan(globalIdx); + }); + + it("excludes lessons scoped to a different project", async () => { + await seedLesson(kv, { + id: "lesson_other", + content: "other-project-lesson", + project: "/tmp/other-project", + confidence: 0.9, + }); + + const result = await handler({ + sessionId: "ses_isolate", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("other-project-lesson"); + }); + + it("excludes deleted lessons", async () => { + await seedLesson(kv, { + id: "lesson_deleted", + content: "tombstoned-lesson", + project: "/tmp/proj", + confidence: 0.9, + deleted: true, + }); + + const result = await handler({ + sessionId: "ses_deleted", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("tombstoned-lesson"); + }); + + it("caps at the top 10 lessons by confidence", async () => { + for (let i = 0; i < 15; i++) { + await seedLesson(kv, { + id: `lesson_${i}`, + content: `lesson-marker-${i}`, + project: "/tmp/proj", + confidence: i / 100, + }); + } + + const result = await handler({ + sessionId: "ses_cap", + project: "/tmp/proj", + }); + + const matched = result.context.match(/lesson-marker-/g) ?? []; + expect(matched.length).toBe(10); + expect(result.context).toContain("lesson-marker-14"); + expect(result.context).toContain("lesson-marker-5"); + expect(result.context).not.toContain("lesson-marker-0"); + }); + + it("shows lesson confidence in the rendered line", async () => { + await seedLesson(kv, { + id: "lesson_conf", + content: "test confidence rendering", + project: "/tmp/proj", + confidence: 0.83, + }); + + const result = await handler({ + sessionId: "ses_conf", + project: "/tmp/proj", + }); + + expect(result.context).toContain("(0.83)"); + }); + + it("appends optional context string when present", async () => { + await seedLesson(kv, { + id: "lesson_ctx", + content: "use TaskCreate for >5-file work", + context: "when working on multi-file refactors", + project: "/tmp/proj", + confidence: 0.8, + }); + + const result = await handler({ + sessionId: "ses_ctx", + project: "/tmp/proj", + }); + + expect(result.context).toContain( + "use TaskCreate for >5-file work — when working on multi-file refactors", + ); + }); +}); diff --git a/test/context-slots.test.ts b/test/context-slots.test.ts new file mode 100644 index 0000000..808597c --- /dev/null +++ b/test/context-slots.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { registerContextFunction } from "../src/functions/context.js"; +import { KV } from "../src/state/schema.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + if (!store.has(scope)) return []; + return Array.from(store.get(scope)!.values()) as T[]; + }, + }; +} + +type ContextHandler = (data: { + sessionId: string; + project: string; + budget?: number; +}) => Promise<{ context: string; blocks: number; tokens: number }>; + +function wireContext(kv: ReturnType) { + let handler: ContextHandler | undefined; + const sdk = { + registerFunction: vi.fn((id: string, cb: ContextHandler) => { + if (id === "mem::context") handler = cb; + }), + } as unknown as import("iii-sdk").ISdk; + registerContextFunction(sdk, kv as never, 2000); + if (!handler) throw new Error("mem::context not registered"); + return handler; +} + +async function seedPinnedSlot( + kv: ReturnType, + label: string, + content: string, + scope: "project" | "global" = "global", +) { + const target = scope === "global" ? KV.globalSlots : KV.slots; + await kv.set(target, label, { + label, + content, + description: "", + sizeLimit: 2000, + pinned: true, + readOnly: false, + scope, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); +} + +describe("mem::context — pinned slot injection", () => { + const ORIGINAL_SLOTS_ENV = process.env["AGENTMEMORY_SLOTS"]; + + afterEach(() => { + if (ORIGINAL_SLOTS_ENV === undefined) { + delete process.env["AGENTMEMORY_SLOTS"]; + } else { + process.env["AGENTMEMORY_SLOTS"] = ORIGINAL_SLOTS_ENV; + } + }); + + describe("when AGENTMEMORY_SLOTS=true", () => { + let kv: ReturnType; + let handler: ContextHandler; + + beforeEach(() => { + process.env["AGENTMEMORY_SLOTS"] = "true"; + kv = mockKV(); + handler = wireContext(kv); + }); + + it("includes pinned global slot content in returned context", async () => { + await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global"); + + const result = await handler({ + sessionId: "ses_a", + project: "/tmp/proj", + }); + + expect(result.context).toContain("tool_guidelines"); + expect(result.context).toContain("rule-alpha"); + expect(result.blocks).toBeGreaterThan(0); + }); + + it("renders multiple pinned slots, sorted by label", async () => { + await seedPinnedSlot(kv, "user_preferences", "pref-alpha", "global"); + await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global"); + + const result = await handler({ + sessionId: "ses_b", + project: "/tmp/proj", + }); + + const guidelinesIdx = result.context.indexOf("tool_guidelines"); + const prefsIdx = result.context.indexOf("user_preferences"); + expect(guidelinesIdx).toBeGreaterThan(-1); + expect(prefsIdx).toBeGreaterThan(-1); + expect(guidelinesIdx).toBeLessThan(prefsIdx); + }); + + it("skips unpinned slots even when they have content", async () => { + await kv.set(KV.globalSlots, "self_notes", { + label: "self_notes", + content: "unpinned-content-alpha", + description: "", + sizeLimit: 1500, + pinned: false, + readOnly: false, + scope: "global", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const result = await handler({ + sessionId: "ses_c", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("unpinned-content-alpha"); + }); + + it("skips empty pinned slots (the seeded defaults)", async () => { + await seedPinnedSlot(kv, "persona", "", "global"); + + const result = await handler({ + sessionId: "ses_d", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("persona"); + }); + + it("project-scoped slot shadows global slot with the same label", async () => { + await seedPinnedSlot(kv, "tool_guidelines", "global-value", "global"); + await seedPinnedSlot(kv, "tool_guidelines", "project-value", "project"); + + const result = await handler({ + sessionId: "ses_e", + project: "/tmp/proj", + }); + + expect(result.context).toContain("project-value"); + expect(result.context).not.toContain("global-value"); + }); + }); + + describe("when AGENTMEMORY_SLOTS is off", () => { + it("does not include any slot content", async () => { + delete process.env["AGENTMEMORY_SLOTS"]; + const kv = mockKV(); + const handler = wireContext(kv); + + await seedPinnedSlot(kv, "tool_guidelines", "rule-alpha", "global"); + + const result = await handler({ + sessionId: "ses_f", + project: "/tmp/proj", + }); + + expect(result.context).not.toContain("tool_guidelines"); + expect(result.context).not.toContain("rule-alpha"); + }); + }); +}); diff --git a/test/copilot-plugin.test.ts b/test/copilot-plugin.test.ts new file mode 100644 index 0000000..cd01b2d --- /dev/null +++ b/test/copilot-plugin.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; + +const repoRoot = resolve(__dirname, ".."); +const pluginRoot = join(repoRoot, "plugin"); + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, "utf-8")) as T; +} + +const SUPPORTED_COPILOT_EVENTS = new Set([ + "sessionStart", + "userPromptSubmitted", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "preCompact", + "agentStop", + "sessionEnd", + "subagentStart", + "subagentStop", + "notification", +]); + +const REQUIRED_MINIMUM_EVENTS = [ + "sessionStart", + "userPromptSubmitted", + "preToolUse", + "postToolUse", + "agentStop", +]; + +const KNOWN_SKILL_DIRS = [ + "recall", + "remember", + "session-history", + "forget", + "handoff", + "recap", + "commit-context", + "commit-history", +]; + +describe("Copilot plugin manifest (plugin/plugin.json)", () => { + it("manifest exists with kebab-case name, version, and required fields", () => { + const manifestPath = join(pluginRoot, "plugin.json"); + expect(existsSync(manifestPath)).toBe(true); + const manifest = readJson<{ + name: string; + version: string; + description?: string; + skills?: string; + mcpServers?: string; + hooks?: string; + }>(manifestPath); + expect(manifest.name).toBe("agentmemory"); + expect(manifest.name).toMatch(/^[a-z][a-z0-9-]*$/); + expect(manifest.version).toMatch(/^\d+\.\d+\.\d+/); + expect(manifest.skills).toBeDefined(); + expect(manifest.mcpServers).toBeDefined(); + expect(manifest.hooks).toBeDefined(); + }); + + it("manifest version matches main package.json", () => { + const pkgVer = readJson<{ version: string }>(join(repoRoot, "package.json")).version; + const pluginVer = readJson<{ version: string }>( + join(pluginRoot, "plugin.json"), + ).version; + expect(pluginVer).toBe(pkgVer); + }); + + it("all referenced manifest paths resolve to existing files / directories", () => { + const manifest = readJson<{ skills: string; mcpServers: string; hooks: string }>( + join(pluginRoot, "plugin.json"), + ); + const manifestDir = pluginRoot; + expect(existsSync(resolve(manifestDir, manifest.skills))).toBe(true); + expect(existsSync(resolve(manifestDir, manifest.mcpServers))).toBe(true); + expect(existsSync(resolve(manifestDir, manifest.hooks))).toBe(true); + }); + + it("skills path resolves and contains all known skill directories", () => { + const manifest = readJson<{ skills: string }>(join(pluginRoot, "plugin.json")); + const manifestDir = pluginRoot; + const skillsPath = resolve(manifestDir, manifest.skills); + for (const skill of KNOWN_SKILL_DIRS) { + expect( + existsSync(join(skillsPath, skill)), + `missing skill directory: ${skill}`, + ).toBe(true); + } + }); +}); + +describe("Copilot MCP config (.mcp.copilot.json)", () => { + it("file exists with expected shape", () => { + const mcpPath = join(pluginRoot, ".mcp.copilot.json"); + expect(existsSync(mcpPath)).toBe(true); + const config = readJson<{ + mcpServers: { + agentmemory: { + type: string; + command: string; + args: string[]; + env: Record; + tools: string[]; + }; + }; + }>(mcpPath); + const server = config.mcpServers.agentmemory; + expect(server.type).toBe("local"); + expect(server.command).toBe("npx"); + expect(server.args).toEqual(["-y", "@agentmemory/mcp"]); + expect(server.env["AGENTMEMORY_URL"]).toBe( + "${AGENTMEMORY_URL:-http://localhost:3111}", + ); + expect(server.env["AGENTMEMORY_SECRET"]).toBe("${AGENTMEMORY_SECRET:-}"); + expect(server.env["AGENTMEMORY_TOOLS"]).toBe("${AGENTMEMORY_TOOLS:-all}"); + expect(server.tools).toContain("*"); + }); +}); + +describe("Copilot hooks config (hooks/hooks.copilot.json)", () => { + type HookEntry = { + type: string; + command?: string; + bash?: string; + powershell?: string; + matcher?: string; + }; + + function loadHooks() { + return readJson<{ version: number; hooks: Record }>( + join(pluginRoot, "hooks/hooks.copilot.json"), + ); + } + + it("has top-level version === 1 and hooks object", () => { + const config = loadHooks(); + expect(config.version).toBe(1); + expect(config.hooks).toBeDefined(); + expect(typeof config.hooks).toBe("object"); + }); + + it("contains only supported Copilot event names", () => { + const config = loadHooks(); + for (const event of Object.keys(config.hooks)) { + expect( + SUPPORTED_COPILOT_EVENTS.has(event), + `unsupported event "${event}" in hooks.copilot.json`, + ).toBe(true); + } + }); + + it("contains all required minimum events", () => { + const config = loadHooks(); + const events = Object.keys(config.hooks); + for (const event of REQUIRED_MINIMUM_EVENTS) { + expect(events, `missing required event: ${event}`).toContain(event); + } + }); + + it("PreToolUse entry has the correct matcher", () => { + const config = loadHooks(); + const preToolEntries = config.hooks["preToolUse"]; + expect(preToolEntries).toBeDefined(); + const withMatcher = preToolEntries.find( + (e) => e.matcher === "edit|write|create|read|view|glob|grep", + ); + expect( + withMatcher, + "PreToolUse must have matcher edit|write|create|read|view|glob|grep", + ).toBeDefined(); + }); + + it("every handler has type === 'command' and exactly one of command/bash/powershell", () => { + const config = loadHooks(); + for (const [event, entries] of Object.entries(config.hooks)) { + for (const handler of entries) { + expect(handler.type, `${event} handler type`).toBe("command"); + const commandFields = [handler.command, handler.bash, handler.powershell].filter( + (v): v is string => typeof v === "string" && v.trim().length > 0, + ); + expect( + commandFields.length, + `${event} handler must have exactly one of command/bash/powershell`, + ).toBe(1); + } + } + }); + + it("every referenced script exists on disk", () => { + const config = loadHooks(); + const scriptRefs = new Set(); + for (const entries of Object.values(config.hooks)) { + for (const handler of entries) { + const cmd = handler.command ?? handler.bash ?? handler.powershell ?? ""; + const match = cmd.match(/\$\{(?:COPILOT_PLUGIN_ROOT|CLAUDE_PLUGIN_ROOT)\}\/(scripts\/[^\s]+)/); + if (match) scriptRefs.add(match[1]); + } + } + expect(scriptRefs.size).toBeGreaterThan(0); + for (const rel of scriptRefs) { + expect(existsSync(join(pluginRoot, rel)), `missing hook script: ${rel}`).toBe(true); + } + }); +}); + +describe("Copilot hook scripts", () => { + type ObservedRequest = { path: string; body: Record }; + + async function runHook( + script: string, + payload: Record, + env: Record = {}, + ): Promise<{ requests: ObservedRequest[]; stdout: string }> { + const requests: ObservedRequest[] = []; + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + requests.push({ + path: req.url ?? "", + body: raw ? (JSON.parse(raw) as Record) : {}, + }); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ context: "remembered context" })); + }); + }); + + await new Promise((resolveServer) => { + server.listen(0, "127.0.0.1", resolveServer); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("test server did not bind to a TCP port"); + } + + try { + const child = spawn(process.execPath, [join(pluginRoot, script)], { + env: { + ...process.env, + AGENTMEMORY_URL: `http://127.0.0.1:${address.port}`, + AGENTMEMORY_SECRET: "", + ...env, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.stdin.end(JSON.stringify(payload)); + + const exitCode = await new Promise((resolveExit, reject) => { + const timeout = setTimeout(() => { + child.kill(); + reject(new Error(`hook ${script} timed out`)); + }, 5000); + child.on("error", reject); + child.on("close", (code) => { + clearTimeout(timeout); + resolveExit(code); + }); + }); + + expect(exitCode, stderr).toBe(0); + return { requests, stdout }; + } finally { + await new Promise((resolveClose) => { + server.close(() => resolveClose()); + }); + } + } + + it("session-start accepts Copilot camelCase sessionId", async () => { + const result = await runHook( + "scripts/session-start.mjs", + { sessionId: "copilot-session", cwd: "C:\\repo" }, + { AGENTMEMORY_INJECT_CONTEXT: "true" }, + ); + + expect(result.stdout).toBe("remembered context"); + expect(result.requests[0]?.path).toBe("/agentmemory/session/start"); + expect(result.requests[0]?.body).toMatchObject({ + sessionId: "copilot-session", + project: "C:\\repo", + cwd: "C:\\repo", + }); + }); + + it("pre-tool-use narrows Copilot sessionId to strings", async () => { + const result = await runHook( + "scripts/pre-tool-use.mjs", + { + sessionId: 123, + toolName: "read", + toolArgs: { path: "src/index.ts" }, + }, + { AGENTMEMORY_INJECT_CONTEXT: "true" }, + ); + + expect(result.stdout).toBe("remembered context"); + expect(result.requests[0]?.path).toBe("/agentmemory/enrich"); + expect(result.requests[0]?.body).toMatchObject({ + sessionId: "unknown", + files: ["src/index.ts"], + terms: [], + toolName: "read", + }); + }); + + it("prompt-submit accepts Copilot camelCase prompt payload", async () => { + const result = await runHook("scripts/prompt-submit.mjs", { + sessionId: "copilot-session", + cwd: "C:\\repo", + userPrompt: "remember this prompt", + }); + + expect(result.requests[0]?.path).toBe("/agentmemory/observe"); + expect(result.requests[0]?.body).toMatchObject({ + hookType: "prompt_submit", + sessionId: "copilot-session", + data: { prompt: "remember this prompt" }, + }); + }); + + it("post-tool-failure accepts Copilot camelCase tool and error payloads", async () => { + const result = await runHook("scripts/post-tool-failure.mjs", { + sessionId: "copilot-session", + cwd: "C:\\repo", + toolName: "edit", + toolArgs: { filePath: "src/index.ts" }, + errorMessage: "failed", + }); + + expect(result.requests[0]?.path).toBe("/agentmemory/observe"); + expect(result.requests[0]?.body).toMatchObject({ + hookType: "post_tool_failure", + sessionId: "copilot-session", + data: { + tool_name: "edit", + tool_input: JSON.stringify({ filePath: "src/index.ts" }), + error: "failed", + }, + }); + }); + + it("notification accepts Copilot camelCase notificationType", async () => { + const result = await runHook("scripts/notification.mjs", { + sessionId: "copilot-session", + cwd: "C:\\repo", + notificationType: "permission_prompt", + title: "Tool approval", + message: "Approve edit", + }); + + expect(result.requests[0]?.path).toBe("/agentmemory/observe"); + expect(result.requests[0]?.body).toMatchObject({ + hookType: "notification", + sessionId: "copilot-session", + data: { + notification_type: "permission_prompt", + title: "Tool approval", + message: "Approve edit", + }, + }); + }); +}); diff --git a/test/cross-project-isolation.test.ts b/test/cross-project-isolation.test.ts new file mode 100644 index 0000000..6d76da3 --- /dev/null +++ b/test/cross-project-isolation.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/keyed-mutex.js", () => ({ + withKeyedLock: (_key: string, fn: () => Promise) => fn(), +})); + +vi.mock("../src/functions/audit.js", () => ({ + recordAudit: vi.fn(), +})); + +vi.mock("../src/functions/access-tracker.js", () => ({ + recordAccessBatch: vi.fn(), + deleteAccessLog: vi.fn(), +})); + +vi.mock("../src/config.js", () => ({ + getAgentId: () => undefined, + isAgentScopeIsolated: () => false, +})); + +import { registerRememberFunction } from "../src/functions/remember.js"; +import { registerSearchFunction, getSearchIndex, setIndexPersistence } from "../src/functions/search.js"; +import { registerEnrichFunction } from "../src/functions/enrich.js"; +import { KV } from "../src/state/schema.js"; +import type { Session } from "../src/types.js"; + +function makeMockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeMockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload; + if (triggerOverrides.has(id)) return triggerOverrides.get(id)!(payload); + const fn = functions.get(id); + if (!fn) throw new Error(`No function registered: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + }; +} + +async function seedSessions(kv: ReturnType) { + const apiSession: Session = { + id: "sess-api", + project: "api", + cwd: "/srv/api", + startedAt: new Date().toISOString(), + status: "active", + observationCount: 0, + }; + const webSession: Session = { + id: "sess-web", + project: "web", + cwd: "/srv/web", + startedAt: new Date().toISOString(), + status: "active", + observationCount: 0, + }; + await kv.set(KV.sessions, apiSession.id, apiSession); + await kv.set(KV.sessions, webSession.id, webSession); +} + +describe("cross-project isolation — end-to-end", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = makeMockSdk(); + kv = makeMockKV(); + + // Disable index persistence in tests so no file I/O occurs. + setIndexPersistence(null); + + // Clear the singleton BM25 index between tests. + getSearchIndex().clear(); + + // Register all three functions against the shared KV. + registerRememberFunction(sdk as never, kv as never); + registerSearchFunction(sdk as never, kv as never); + + // Enrich calls mem::search internally; wire the file-context trigger as a no-op. + registerEnrichFunction(sdk as never, kv as never); + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + + await seedSessions(kv); + }); + + it("bug memory scoped to api does not appear in enrich context for web project", async () => { + await sdk.trigger("mem::remember", { + content: "express-jwt throws 401 when Authorization header has extra whitespace. Call .trim() before passing to middleware.", + type: "bug", + files: ["src/middleware/auth.ts"], + project: "api", + }); + + const result = await sdk.trigger("mem::enrich", { + sessionId: "sess-web", + files: ["src/middleware/auth.ts"], + project: "web", + }) as { context: string }; + + expect(result.context).not.toContain("agentmemory-past-errors"); + expect(result.context).not.toContain("express-jwt"); + }); + + it("bug memory scoped to api appears in enrich context for api project", async () => { + await sdk.trigger("mem::remember", { + content: "express-jwt throws 401 when Authorization header has extra whitespace. Call .trim() before passing to middleware.", + type: "bug", + files: ["src/middleware/auth.ts"], + project: "api", + }); + + const result = await sdk.trigger("mem::enrich", { + sessionId: "sess-api", + files: ["src/middleware/auth.ts"], + project: "api", + }) as { context: string }; + + expect(result.context).toContain("agentmemory-past-errors"); + expect(result.context).toContain("express-jwt"); + }); + + it("bug memory scoped to api is excluded from search results for web project", async () => { + await sdk.trigger("mem::remember", { + content: "express-jwt throws 401 when Authorization header has extra whitespace", + type: "bug", + files: ["src/middleware/auth.ts"], + project: "api", + }); + + // Force index rebuild so the freshly saved memory is indexed. + getSearchIndex().clear(); + + const result = await sdk.trigger("mem::search", { + query: "express-jwt whitespace", + project: "web", + }) as { results: Array<{ observation: { title: string; narrative?: string } }> }; + + const titles = result.results.map((r) => r.observation.title); + expect(titles.join(" ")).not.toContain("express-jwt"); + }); + + it("bug memory scoped to api is included in search results for api project", async () => { + await sdk.trigger("mem::remember", { + content: "express-jwt throws 401 when Authorization header has extra whitespace", + type: "bug", + files: ["src/middleware/auth.ts"], + project: "api", + }); + + // Force index rebuild so the freshly saved memory is indexed. + getSearchIndex().clear(); + + const result = await sdk.trigger("mem::search", { + query: "express-jwt whitespace", + project: "api", + }) as { results: Array<{ observation: { title: string; narrative?: string } }> }; + + const combined = result.results + .map((r) => `${r.observation.title} ${r.observation.narrative ?? ""}`) + .join(" "); + expect(combined).toContain("express-jwt"); + }); + + it("two projects with overlapping filenames see only their own bug memories", async () => { + await sdk.trigger("mem::remember", { + content: "express-jwt Authorization header whitespace causes 401", + type: "bug", + files: ["src/middleware/auth.ts"], + project: "api", + }); + await sdk.trigger("mem::remember", { + content: "nextauth cookie domain mismatch breaks SSO on subdomains", + type: "bug", + files: ["src/middleware/auth.ts"], + project: "web", + }); + + const apiEnrich = await sdk.trigger("mem::enrich", { + sessionId: "sess-api", + files: ["src/middleware/auth.ts"], + project: "api", + }) as { context: string }; + + expect(apiEnrich.context).toContain("express-jwt"); + expect(apiEnrich.context).not.toContain("nextauth"); + + const webEnrich = await sdk.trigger("mem::enrich", { + sessionId: "sess-web", + files: ["src/middleware/auth.ts"], + project: "web", + }) as { context: string }; + + expect(webEnrich.context).toContain("nextauth"); + expect(webEnrich.context).not.toContain("express-jwt"); + }); + + it("unscoped (legacy) bug memory is visible to both projects", async () => { + await sdk.trigger("mem::remember", { + content: "generic auth middleware always validates content-type header", + type: "bug", + files: ["src/middleware/auth.ts"], + // no project — legacy / unscoped + }); + + const apiResult = await sdk.trigger("mem::enrich", { + sessionId: "sess-api", + files: ["src/middleware/auth.ts"], + project: "api", + }) as { context: string }; + + const webResult = await sdk.trigger("mem::enrich", { + sessionId: "sess-web", + files: ["src/middleware/auth.ts"], + project: "web", + }) as { context: string }; + + expect(apiResult.context).toContain("generic auth middleware"); + expect(webResult.context).toContain("generic auth middleware"); + }); + + it("memories from different projects do not supersede each other via Jaccard dedup", async () => { + const sharedContent = "jwt token must be trimmed before validation in the middleware layer"; + + await sdk.trigger("mem::remember", { + content: sharedContent, + type: "bug", + files: ["src/middleware/auth.ts"], + project: "api", + }); + + // Save nearly identical content under a different project. + const result = await sdk.trigger("mem::remember", { + content: sharedContent, + type: "bug", + files: ["src/middleware/auth.ts"], + project: "web", + }) as { memory: { id: string; project: string } }; + + // Both memories must survive as independent latest entries. + const memories = await kv.list(KV.memories) as Array<{ isLatest: boolean; project: string }>; + const latestByProject = memories.filter((m) => m.isLatest); + const projects = latestByProject.map((m) => m.project); + + expect(projects).toContain("api"); + expect(projects).toContain("web"); + expect(result.memory.project).toBe("web"); + }); +}); diff --git a/test/crystallize.test.ts b/test/crystallize.test.ts new file mode 100644 index 0000000..cbb29f5 --- /dev/null +++ b/test/crystallize.test.ts @@ -0,0 +1,522 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerCrystallizeFunction } from "../src/functions/crystallize.js"; +import type { Action, Crystal, MemoryProvider } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function mockProvider(): MemoryProvider { + return { + name: "test", + compress: vi.fn(), + summarize: vi.fn().mockResolvedValue( + '{"narrative":"test","keyOutcomes":["done"],"filesAffected":["a.ts"],"lessons":["learned"]}', + ), + }; +} + +function makeAction(overrides: Partial & { id: string }): Action { + return { + title: "Test action", + description: "A test action", + status: "done", + priority: 5, + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date().toISOString(), + createdBy: "agent-1", + tags: [], + sourceObservationIds: [], + sourceMemoryIds: [], + ...overrides, + }; +} + +describe("Crystallize Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + let provider: MemoryProvider; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + provider = mockProvider(); + registerCrystallizeFunction(sdk as never, kv as never, provider); + }); + + describe("mem::crystallize", () => { + it("crystallizes completed actions with valid JSON response", async () => { + const action = makeAction({ id: "act_1", title: "Fix bug", status: "done" }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::crystallize", { + actionIds: ["act_1"], + project: "webapp", + sessionId: "sess_1", + })) as { success: boolean; crystal: Crystal }; + + expect(result.success).toBe(true); + expect(result.crystal.id).toMatch(/^crys_/); + expect(result.crystal.narrative).toBe("test"); + expect(result.crystal.keyOutcomes).toEqual(["done"]); + expect(result.crystal.filesAffected).toEqual(["a.ts"]); + expect(result.crystal.lessons).toEqual(["learned"]); + expect(result.crystal.sourceActionIds).toEqual(["act_1"]); + expect(result.crystal.project).toBe("webapp"); + expect(result.crystal.sessionId).toBe("sess_1"); + expect(result.crystal.createdAt).toBeDefined(); + }); + + it("marks source actions with crystallizedInto", async () => { + const action = makeAction({ id: "act_mark", status: "done" }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::crystallize", { + actionIds: ["act_mark"], + })) as { success: boolean; crystal: Crystal }; + + expect(result.success).toBe(true); + + const updated = await kv.get("mem:actions", "act_mark"); + expect(updated!.crystallizedInto).toBe(result.crystal.id); + }); + + it("falls back to raw text when provider returns non-JSON", async () => { + (provider.summarize as ReturnType).mockResolvedValue( + "Just a plain text summary with no JSON.", + ); + + const action = makeAction({ id: "act_nojson", status: "done" }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::crystallize", { + actionIds: ["act_nojson"], + })) as { success: boolean; crystal: Crystal }; + + expect(result.success).toBe(true); + expect(result.crystal.narrative).toBe( + "Just a plain text summary with no JSON.", + ); + expect(result.crystal.keyOutcomes).toEqual([]); + expect(result.crystal.filesAffected).toEqual([]); + expect(result.crystal.lessons).toEqual([]); + }); + + it("returns error for non-existent action", async () => { + const result = (await sdk.trigger("mem::crystallize", { + actionIds: ["act_ghost"], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("action not found: act_ghost"); + }); + + it("returns error for non-done action", async () => { + const action = makeAction({ id: "act_pending", status: "pending" }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::crystallize", { + actionIds: ["act_pending"], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('status "pending"'); + }); + + it("returns error for empty actionIds", async () => { + const result = (await sdk.trigger("mem::crystallize", { + actionIds: [], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("actionIds is required"); + }); + + it("returns error when actionIds is missing", async () => { + const result = (await sdk.trigger("mem::crystallize", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("actionIds is required"); + }); + + it("accepts cancelled actions", async () => { + const action = makeAction({ id: "act_cancel", status: "cancelled" }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::crystallize", { + actionIds: ["act_cancel"], + })) as { success: boolean; crystal: Crystal }; + + expect(result.success).toBe(true); + expect(result.crystal.sourceActionIds).toEqual(["act_cancel"]); + }); + + it("crystallizes multiple actions in one call", async () => { + const a1 = makeAction({ id: "act_m1", status: "done", title: "First" }); + const a2 = makeAction({ id: "act_m2", status: "done", title: "Second" }); + await kv.set("mem:actions", a1.id, a1); + await kv.set("mem:actions", a2.id, a2); + + const result = (await sdk.trigger("mem::crystallize", { + actionIds: ["act_m1", "act_m2"], + })) as { success: boolean; crystal: Crystal }; + + expect(result.success).toBe(true); + expect(result.crystal.sourceActionIds).toEqual(["act_m1", "act_m2"]); + }); + + it("returns failure when provider throws", async () => { + (provider.summarize as ReturnType).mockRejectedValue( + new Error("API down"), + ); + + const action = makeAction({ id: "act_fail", status: "done" }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::crystallize", { + actionIds: ["act_fail"], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("crystallization failed"); + expect(result.error).toContain("API down"); + }); + }); + + describe("mem::crystal-list", () => { + beforeEach(async () => { + const c1: Crystal = { + id: "crys_1", + narrative: "First crystal", + keyOutcomes: [], + filesAffected: [], + lessons: [], + sourceActionIds: ["act_1"], + project: "alpha", + sessionId: "sess_a", + createdAt: new Date("2025-01-01").toISOString(), + }; + const c2: Crystal = { + id: "crys_2", + narrative: "Second crystal", + keyOutcomes: [], + filesAffected: [], + lessons: [], + sourceActionIds: ["act_2"], + project: "beta", + sessionId: "sess_b", + createdAt: new Date("2025-02-01").toISOString(), + }; + const c3: Crystal = { + id: "crys_3", + narrative: "Third crystal", + keyOutcomes: [], + filesAffected: [], + lessons: [], + sourceActionIds: ["act_3"], + project: "alpha", + sessionId: "sess_a", + createdAt: new Date("2025-03-01").toISOString(), + }; + await kv.set("mem:crystals", c1.id, c1); + await kv.set("mem:crystals", c2.id, c2); + await kv.set("mem:crystals", c3.id, c3); + }); + + it("returns all crystals sorted by createdAt desc", async () => { + const result = (await sdk.trigger("mem::crystal-list", {})) as { + success: boolean; + crystals: Crystal[]; + }; + + expect(result.success).toBe(true); + expect(result.crystals.length).toBe(3); + expect(result.crystals[0].id).toBe("crys_3"); + expect(result.crystals[1].id).toBe("crys_2"); + expect(result.crystals[2].id).toBe("crys_1"); + }); + + it("filters by project", async () => { + const result = (await sdk.trigger("mem::crystal-list", { + project: "alpha", + })) as { success: boolean; crystals: Crystal[] }; + + expect(result.success).toBe(true); + expect(result.crystals.length).toBe(2); + expect(result.crystals.every((c) => c.project === "alpha")).toBe(true); + }); + + it("filters by sessionId", async () => { + const result = (await sdk.trigger("mem::crystal-list", { + sessionId: "sess_b", + })) as { success: boolean; crystals: Crystal[] }; + + expect(result.success).toBe(true); + expect(result.crystals.length).toBe(1); + expect(result.crystals[0].id).toBe("crys_2"); + }); + + it("respects limit", async () => { + const result = (await sdk.trigger("mem::crystal-list", { + limit: 1, + })) as { success: boolean; crystals: Crystal[] }; + + expect(result.success).toBe(true); + expect(result.crystals.length).toBe(1); + expect(result.crystals[0].id).toBe("crys_3"); + }); + }); + + describe("mem::crystal-get", () => { + it("returns crystal by id", async () => { + const crystal: Crystal = { + id: "crys_get_1", + narrative: "Found it", + keyOutcomes: ["yes"], + filesAffected: ["b.ts"], + lessons: ["test"], + sourceActionIds: ["act_x"], + createdAt: new Date().toISOString(), + }; + await kv.set("mem:crystals", crystal.id, crystal); + + const result = (await sdk.trigger("mem::crystal-get", { + crystalId: "crys_get_1", + })) as { success: boolean; crystal: Crystal }; + + expect(result.success).toBe(true); + expect(result.crystal.id).toBe("crys_get_1"); + expect(result.crystal.narrative).toBe("Found it"); + }); + + it("returns error for non-existent crystal", async () => { + const result = (await sdk.trigger("mem::crystal-get", { + crystalId: "crys_missing", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("crystal not found"); + }); + + it("returns error when crystalId is missing", async () => { + const result = (await sdk.trigger("mem::crystal-get", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("crystalId is required"); + }); + }); + + describe("mem::auto-crystallize", () => { + it("returns group summaries in dryRun mode", async () => { + const action = makeAction({ + id: "act_dry", + status: "done", + project: "proj", + }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::auto-crystallize", { + dryRun: true, + })) as { + success: boolean; + dryRun: boolean; + groupCount: number; + groups: { groupKey: string; actionCount: number; actionIds: string[] }[]; + crystalIds: string[]; + }; + + expect(result.success).toBe(true); + expect(result.dryRun).toBe(true); + expect(result.groupCount).toBe(1); + expect(result.groups[0].actionIds).toContain("act_dry"); + expect(result.crystalIds).toEqual([]); + }); + + it("groups by parentId when present", async () => { + const parent = makeAction({ + id: "act_parent", + status: "done", + parentId: undefined, + }); + const child1 = makeAction({ + id: "act_child1", + status: "done", + parentId: "act_parent", + }); + const child2 = makeAction({ + id: "act_child2", + status: "done", + parentId: "act_parent", + }); + await kv.set("mem:actions", parent.id, parent); + await kv.set("mem:actions", child1.id, child1); + await kv.set("mem:actions", child2.id, child2); + + const result = (await sdk.trigger("mem::auto-crystallize", { + dryRun: true, + })) as { + success: boolean; + groups: { groupKey: string; actionCount: number; actionIds: string[] }[]; + }; + + expect(result.success).toBe(true); + const parentGroup = result.groups.find((g) => g.groupKey === "act_parent"); + expect(parentGroup).toBeDefined(); + expect(parentGroup!.actionCount).toBe(2); + }); + + it("groups by project when no parentId", async () => { + const a1 = makeAction({ id: "act_proj1", status: "done", project: "webapp" }); + const a2 = makeAction({ id: "act_proj2", status: "done", project: "webapp" }); + const a3 = makeAction({ id: "act_proj3", status: "done", project: "api" }); + await kv.set("mem:actions", a1.id, a1); + await kv.set("mem:actions", a2.id, a2); + await kv.set("mem:actions", a3.id, a3); + + const result = (await sdk.trigger("mem::auto-crystallize", { + dryRun: true, + })) as { + success: boolean; + groups: { groupKey: string; actionCount: number }[]; + }; + + expect(result.success).toBe(true); + const webGroup = result.groups.find((g) => g.groupKey === "webapp"); + const apiGroup = result.groups.find((g) => g.groupKey === "api"); + expect(webGroup).toBeDefined(); + expect(webGroup!.actionCount).toBe(2); + expect(apiGroup).toBeDefined(); + expect(apiGroup!.actionCount).toBe(1); + }); + + it("skips already-crystallized actions", async () => { + const action = makeAction({ + id: "act_already", + status: "done", + crystallizedInto: "crys_existing", + }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::auto-crystallize", { + dryRun: true, + })) as { success: boolean; groupCount: number }; + + expect(result.success).toBe(true); + expect(result.groupCount).toBe(0); + }); + + it("skips actions newer than threshold", async () => { + const recentAction = makeAction({ + id: "act_recent", + status: "done", + createdAt: new Date().toISOString(), + }); + await kv.set("mem:actions", recentAction.id, recentAction); + + const result = (await sdk.trigger("mem::auto-crystallize", { + olderThanDays: 7, + dryRun: true, + })) as { success: boolean; groupCount: number }; + + expect(result.success).toBe(true); + expect(result.groupCount).toBe(0); + }); + + it("creates crystals for each group in non-dryRun mode", async () => { + const a1 = makeAction({ id: "act_auto1", status: "done", project: "proj1" }); + const a2 = makeAction({ id: "act_auto2", status: "done", project: "proj2" }); + await kv.set("mem:actions", a1.id, a1); + await kv.set("mem:actions", a2.id, a2); + + const result = (await sdk.trigger("mem::auto-crystallize", {})) as { + success: boolean; + groupCount: number; + crystalIds: string[]; + }; + + expect(result.success).toBe(true); + expect(result.groupCount).toBe(2); + expect(result.crystalIds.length).toBe(2); + expect(result.crystalIds[0]).toMatch(/^crys_/); + expect(result.crystalIds[1]).toMatch(/^crys_/); + }); + + it("filters by project when specified", async () => { + const a1 = makeAction({ id: "act_fp1", status: "done", project: "keep" }); + const a2 = makeAction({ id: "act_fp2", status: "done", project: "skip" }); + await kv.set("mem:actions", a1.id, a1); + await kv.set("mem:actions", a2.id, a2); + + const result = (await sdk.trigger("mem::auto-crystallize", { + project: "keep", + dryRun: true, + })) as { + success: boolean; + groupCount: number; + groups: { groupKey: string; actionCount: number }[]; + }; + + expect(result.success).toBe(true); + expect(result.groupCount).toBe(1); + expect(result.groups[0].groupKey).toBe("keep"); + }); + + it("returns empty when no qualifying actions exist", async () => { + const result = (await sdk.trigger("mem::auto-crystallize", {})) as { + success: boolean; + groupCount: number; + crystalIds: string[]; + }; + + expect(result.success).toBe(true); + expect(result.groupCount).toBe(0); + expect(result.crystalIds).toEqual([]); + }); + }); +}); diff --git a/test/diagnostic-followup-rate.test.ts b/test/diagnostic-followup-rate.test.ts new file mode 100644 index 0000000..d747e33 --- /dev/null +++ b/test/diagnostic-followup-rate.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { + registerSmartSearchFunction, + getFollowupStats, + resetFollowupStatsForTests, + flushPendingFollowups, +} from "../src/functions/smart-search.js"; +import { registerRecentSearchesSweepFunction } from "../src/functions/recent-searches-sweep.js"; +import { KV } from "../src/state/schema.js"; +import type { HybridSearchResult } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => + Array.from(store.get(scope)?.values() ?? []) as T[], + getStore: () => store, + }; +} + +function mockSdk(kv: ReturnType) { + const functions = new Map(); + const sdk = { + registerFunction: ( + idOrOpts: string | { id: string }, + handler: Function, + ) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload?: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = + typeof idOrInput === "string" ? data : (idOrInput as any).payload; + const fn = functions.get(id); + if (!fn) { + if (id === "mem::lesson-recall") return { success: true, lessons: [] }; + throw new Error(`No function: ${id}`); + } + const result = await fn(payload); + // smart-search now runs followup detection off the critical + // response path; drain it before returning so test assertions + // see consistent state. + if (id === "mem::smart-search") await flushPendingFollowups(); + return result; + }, + } as any; + void kv; + return sdk; +} + +function makeHit(obsId: string, sessionId = "ses_1"): HybridSearchResult { + return { + observation: { + id: obsId, + sessionId, + timestamp: new Date().toISOString(), + title: `obs ${obsId}`, + narrative: "n", + type: "pattern", + concepts: [], + files: [], + } as any, + sessionId, + combinedScore: 0.8, + } as HybridSearchResult; +} + +describe("Smart-search followup-rate diagnostic (#771)", () => { + let sdk: any; + let kv: ReturnType; + let searchResults: HybridSearchResult[]; + + beforeEach(() => { + delete process.env.AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS; + resetFollowupStatsForTests(); + kv = mockKV(); + sdk = mockSdk(kv); + searchResults = []; + registerSmartSearchFunction(sdk, kv as any, async () => searchResults); + registerRecentSearchesSweepFunction(sdk, kv as any); + }); + + afterEach(() => { + delete process.env.AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS; + }); + + it("records the first agent-initiated search but does not flag it as a followup", async () => { + searchResults = [makeHit("obs_a"), makeHit("obs_b")]; + await sdk.trigger("mem::smart-search", { + query: "auth flow", + sessionId: "ses_1", + }); + + const stats = getFollowupStats(); + expect(stats.agentInitiatedSearches).toBe(1); + expect(stats.followupWithinWindow).toBe(0); + + const stored = await kv.get(KV.recentSearches, "ses_1"); + expect(stored).not.toBeNull(); + expect((stored as any).sessionId).toBe("ses_1"); + expect((stored as any).query).toBe("auth flow"); + }); + + it("flags a follow-up when the second search inside the window returns a disjoint set", async () => { + searchResults = [makeHit("obs_a"), makeHit("obs_b")]; + await sdk.trigger("mem::smart-search", { + query: "auth flow", + sessionId: "ses_1", + }); + + searchResults = [makeHit("obs_c"), makeHit("obs_d")]; + await sdk.trigger("mem::smart-search", { + query: "token expiry handling", + sessionId: "ses_1", + }); + + const stats = getFollowupStats(); + expect(stats.agentInitiatedSearches).toBe(2); + expect(stats.followupWithinWindow).toBe(1); + expect(stats.rate).toBeCloseTo(0.5); + }); + + it("does not flag a follow-up when result sets overlap", async () => { + searchResults = [makeHit("obs_a"), makeHit("obs_b")]; + await sdk.trigger("mem::smart-search", { + query: "auth flow", + sessionId: "ses_1", + }); + + searchResults = [makeHit("obs_b"), makeHit("obs_c")]; + await sdk.trigger("mem::smart-search", { + query: "auth token", + sessionId: "ses_1", + }); + + const stats = getFollowupStats(); + expect(stats.followupWithinWindow).toBe(0); + }); + + it("does not flag a follow-up on an identical re-query (retry, not follow-up)", async () => { + searchResults = [makeHit("obs_a")]; + await sdk.trigger("mem::smart-search", { + query: "auth flow", + sessionId: "ses_1", + }); + + // Different result set on the retry (e.g. flaky search index), but + // same query — still not a follow-up. + searchResults = [makeHit("obs_b")]; + await sdk.trigger("mem::smart-search", { + query: "auth flow", + sessionId: "ses_1", + }); + + expect(getFollowupStats().followupWithinWindow).toBe(0); + }); + + it("does not flag a follow-up when prior search is outside the window", async () => { + process.env.AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS = "1"; + searchResults = [makeHit("obs_a")]; + await sdk.trigger("mem::smart-search", { + query: "first", + sessionId: "ses_1", + }); + + // Backdate the stored search so the window has elapsed. + const stored = (await kv.get(KV.recentSearches, "ses_1")) as any; + stored.at = Date.now() - 5_000; + await kv.set(KV.recentSearches, "ses_1", stored); + + searchResults = [makeHit("obs_b")]; + await sdk.trigger("mem::smart-search", { + query: "second", + sessionId: "ses_1", + }); + + expect(getFollowupStats().followupWithinWindow).toBe(0); + }); + + it("skips viewer-originated searches (source === 'viewer')", async () => { + searchResults = [makeHit("obs_a")]; + await sdk.trigger("mem::smart-search", { + query: "from viewer", + sessionId: "ses_1", + source: "viewer", + }); + + expect(getFollowupStats().agentInitiatedSearches).toBe(0); + // Viewer call shouldn't write to recent-searches either, otherwise + // a subsequent agent call would treat the viewer search as prior. + expect(await kv.get(KV.recentSearches, "ses_1")).toBeNull(); + }); + + it("skips searches without a sessionId (direct sdk callers)", async () => { + searchResults = [makeHit("obs_a")]; + await sdk.trigger("mem::smart-search", { query: "no session" }); + + expect(getFollowupStats().agentInitiatedSearches).toBe(0); + }); + + it("recent-searches sweep deletes rows older than 24h, keeps fresh ones", async () => { + const fresh = { + sessionId: "ses_fresh", + query: "x", + resultIds: [], + at: Date.now() - 1_000, + }; + const stale = { + sessionId: "ses_stale", + query: "x", + resultIds: [], + at: Date.now() - 25 * 60 * 60 * 1000, + }; + await kv.set(KV.recentSearches, fresh.sessionId, fresh); + await kv.set(KV.recentSearches, stale.sessionId, stale); + + const result = (await sdk.trigger( + "mem::diagnostic::recent-searches-sweep", + {}, + )) as { swept: number }; + + expect(result.swept).toBe(1); + expect(await kv.get(KV.recentSearches, "ses_fresh")).not.toBeNull(); + expect(await kv.get(KV.recentSearches, "ses_stale")).toBeNull(); + }); + + it("skips detection when current results are empty (retrieval failure, not reader failure)", async () => { + searchResults = [makeHit("obs_a"), makeHit("obs_b")]; + await sdk.trigger("mem::smart-search", { + query: "first", + sessionId: "ses_1", + }); + + // Empty result set on the next call. Without the empty-skip guard + // the empty-vs-prior comparison would be vacuously "disjoint" and + // inflate the rate. Skip detection entirely. + searchResults = []; + await sdk.trigger("mem::smart-search", { + query: "second", + sessionId: "ses_1", + }); + + const stats = getFollowupStats(); + // Only the first call counts as agent-initiated; the empty-result + // second call is skipped entirely. + expect(stats.agentInitiatedSearches).toBe(1); + expect(stats.followupWithinWindow).toBe(0); + }); + + it("followup-stats function returns the configured window and live counts", async () => { + process.env.AGENTMEMORY_FOLLOWUP_WINDOW_SECONDS = "45"; + searchResults = [makeHit("obs_a")]; + await sdk.trigger("mem::smart-search", { + query: "q1", + sessionId: "ses_1", + }); + searchResults = [makeHit("obs_b")]; + await sdk.trigger("mem::smart-search", { + query: "q2", + sessionId: "ses_1", + }); + + const stats = (await sdk.trigger( + "mem::diagnostic::followup-stats", + {}, + )) as { + success: boolean; + windowSeconds: number; + agentInitiatedSearches: number; + followupWithinWindow: number; + rate: number; + }; + + expect(stats.success).toBe(true); + expect(stats.windowSeconds).toBe(45); + expect(stats.agentInitiatedSearches).toBe(2); + expect(stats.followupWithinWindow).toBe(1); + expect(stats.rate).toBeCloseTo(0.5); + }); +}); diff --git a/test/diagnostics.test.ts b/test/diagnostics.test.ts new file mode 100644 index 0000000..1e16876 --- /dev/null +++ b/test/diagnostics.test.ts @@ -0,0 +1,868 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerDiagnosticsFunction } from "../src/functions/diagnostics.js"; +import type { + Action, + ActionEdge, + DiagnosticCheck, + Lease, + Sentinel, + Sketch, + Signal, + Session, + Memory, + MeshPeer, +} from "../src/types.js"; +import { KV } from "../src/state/schema.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeAction(overrides: Partial = {}): Action { + return { + id: `act_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + title: "Test action", + description: "", + status: "pending", + priority: 5, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: "agent-1", + tags: [], + sourceObservationIds: [], + sourceMemoryIds: [], + ...overrides, + }; +} + +function makeLease(overrides: Partial = {}): Lease { + return { + id: `lease_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + actionId: "act_missing", + agentId: "agent-1", + acquiredAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + status: "active", + ...overrides, + }; +} + +function makeEdge(overrides: Partial = {}): ActionEdge { + return { + id: `ae_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + type: "requires", + sourceActionId: "src", + targetActionId: "tgt", + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeSentinel(overrides: Partial = {}): Sentinel { + return { + id: `sen_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + name: "Test sentinel", + type: "timer", + status: "watching", + config: {}, + createdAt: new Date().toISOString(), + linkedActionIds: [], + ...overrides, + }; +} + +function makeSketch(overrides: Partial = {}): Sketch { + return { + id: `sk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + title: "Test sketch", + description: "", + status: "active", + actionIds: [], + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ...overrides, + }; +} + +function makeSignal(overrides: Partial = {}): Signal { + return { + id: `sig_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + from: "agent-1", + type: "info", + content: "test", + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function makeSession(overrides: Partial = {}): Session { + return { + id: `ses_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + project: "test", + cwd: "/tmp", + startedAt: new Date().toISOString(), + status: "active", + observationCount: 0, + ...overrides, + }; +} + +function makeMemory(overrides: Partial = {}): Memory { + return { + id: `mem_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "fact", + title: "Test memory", + content: "content", + concepts: [], + files: [], + sessionIds: [], + strength: 1, + version: 1, + isLatest: true, + ...overrides, + }; +} + +function makePeer(overrides: Partial = {}): MeshPeer { + return { + id: `peer_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + url: "http://localhost:3111", + name: "Test peer", + status: "connected", + sharedScopes: [], + ...overrides, + }; +} + +describe("Diagnostics Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerDiagnosticsFunction(sdk as never, kv as never); + }); + + describe("mem::diagnose", () => { + it("empty system passes all checks", async () => { + const result = (await sdk.trigger("mem::diagnose", {})) as { + success: boolean; + checks: DiagnosticCheck[]; + summary: { pass: number; warn: number; fail: number; fixable: number }; + }; + + expect(result.success).toBe(true); + // 15 = 8 original (actions, leases, sentinels, sketches, signals, + // sessions, memories, mesh) + 6 added in #lesson-visibility + // (lessons, summaries, semantic, procedural, crystals, insights) + + // 1 added in #memory-project-scope (memory-project-coverage). + expect(result.summary.pass).toBe(15); + expect(result.summary.warn).toBe(0); + expect(result.summary.fail).toBe(0); + expect(result.summary.fixable).toBe(0); + expect(result.checks.every((c) => c.status === "pass")).toBe(true); + }); + + it("active action with no lease produces warn", async () => { + const action = makeAction({ status: "active" }); + await kv.set(KV.actions, action.id, action); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["actions"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("active-no-lease:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("warn"); + expect(check!.fixable).toBe(false); + }); + + it("blocked action with all deps done produces fail (fixable)", async () => { + const dep = makeAction({ status: "done" }); + const blocked = makeAction({ status: "blocked" }); + const edge = makeEdge({ + sourceActionId: blocked.id, + targetActionId: dep.id, + type: "requires", + }); + await kv.set(KV.actions, dep.id, dep); + await kv.set(KV.actions, blocked.id, blocked); + await kv.set(KV.actionEdges, edge.id, edge); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["actions"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("blocked-deps-done:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("fail"); + expect(check!.fixable).toBe(true); + }); + + it("pending action with unsatisfied deps produces fail (fixable)", async () => { + const dep = makeAction({ status: "active" }); + const pending = makeAction({ status: "pending" }); + const edge = makeEdge({ + sourceActionId: pending.id, + targetActionId: dep.id, + type: "requires", + }); + await kv.set(KV.actions, dep.id, dep); + await kv.set(KV.actions, pending.id, pending); + await kv.set(KV.actionEdges, edge.id, edge); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["actions"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("pending-unsatisfied-deps:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("fail"); + expect(check!.fixable).toBe(true); + }); + + it("expired active lease produces fail (fixable)", async () => { + const action = makeAction({ status: "active" }); + const lease = makeLease({ + actionId: action.id, + status: "active", + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + await kv.set(KV.actions, action.id, action); + await kv.set(KV.leases, lease.id, lease); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["leases"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("expired-lease:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("fail"); + expect(check!.fixable).toBe(true); + }); + + it("orphaned lease (action gone) produces fail (fixable)", async () => { + const lease = makeLease({ + actionId: "act_gone", + status: "active", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + await kv.set(KV.leases, lease.id, lease); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["leases"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("orphaned-lease:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("fail"); + expect(check!.fixable).toBe(true); + }); + + it("expired watching sentinel produces fail (fixable)", async () => { + const sentinel = makeSentinel({ + status: "watching", + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + await kv.set(KV.sentinels, sentinel.id, sentinel); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["sentinels"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("expired-sentinel:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("fail"); + expect(check!.fixable).toBe(true); + }); + + it("sentinel referencing missing action produces warn", async () => { + const sentinel = makeSentinel({ + linkedActionIds: ["act_nonexistent"], + }); + await kv.set(KV.sentinels, sentinel.id, sentinel); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["sentinels"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("sentinel-missing-action:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("warn"); + expect(check!.fixable).toBe(false); + }); + + it("expired active sketch produces fail (fixable)", async () => { + const sketch = makeSketch({ + status: "active", + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + await kv.set(KV.sketches, sketch.id, sketch); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["sketches"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("expired-sketch:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("fail"); + expect(check!.fixable).toBe(true); + }); + + it("expired signal produces fail (fixable)", async () => { + const signal = makeSignal({ + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + await kv.set(KV.signals, signal.id, signal); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["signals"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("expired-signal:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("fail"); + expect(check!.fixable).toBe(true); + }); + + it("active session older than 24h produces warn", async () => { + const session = makeSession({ + status: "active", + startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), + }); + await kv.set(KV.sessions, session.id, session); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["sessions"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("abandoned-session:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("warn"); + expect(check!.fixable).toBe(false); + }); + + it("memory with stale isLatest produces fail (fixable)", async () => { + const oldMemory = makeMemory({ isLatest: true }); + const newMemory = makeMemory({ supersedes: [oldMemory.id] }); + await kv.set(KV.memories, oldMemory.id, oldMemory); + await kv.set(KV.memories, newMemory.id, newMemory); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["memories"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("memory-stale-latest:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("fail"); + expect(check!.fixable).toBe(true); + }); + + it("memory superseding non-existent produces warn", async () => { + const memory = makeMemory({ supersedes: ["mem_gone"] }); + await kv.set(KV.memories, memory.id, memory); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["memories"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("memory-missing-supersedes:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("warn"); + expect(check!.fixable).toBe(false); + }); + + it("stale mesh peer produces warn", async () => { + const peer = makePeer({ + lastSyncAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + }); + await kv.set(KV.mesh, peer.id, peer); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["mesh"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("stale-peer:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("warn"); + expect(check!.fixable).toBe(false); + }); + + it("error mesh peer produces warn", async () => { + const peer = makePeer({ status: "error" }); + await kv.set(KV.mesh, peer.id, peer); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["mesh"], + })) as { checks: DiagnosticCheck[] }; + + const check = result.checks.find((c) => + c.name.startsWith("error-peer:"), + ); + expect(check).toBeDefined(); + expect(check!.status).toBe("warn"); + expect(check!.fixable).toBe(false); + }); + + it("filters by categories", async () => { + const action = makeAction({ status: "active" }); + await kv.set(KV.actions, action.id, action); + + const signal = makeSignal({ + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + await kv.set(KV.signals, signal.id, signal); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["signals"], + })) as { checks: DiagnosticCheck[] }; + + expect(result.checks.every((c) => c.category === "signals")).toBe(true); + expect( + result.checks.some((c) => c.category === "actions"), + ).toBe(false); + }); + }); + + describe("mem::heal", () => { + it("unblocks stuck blocked action", async () => { + const dep = makeAction({ status: "done" }); + const blocked = makeAction({ status: "blocked", title: "Stuck task" }); + const edge = makeEdge({ + sourceActionId: blocked.id, + targetActionId: dep.id, + type: "requires", + }); + await kv.set(KV.actions, dep.id, dep); + await kv.set(KV.actions, blocked.id, blocked); + await kv.set(KV.actionEdges, edge.id, edge); + + const result = (await sdk.trigger("mem::heal", { + categories: ["actions"], + })) as { success: boolean; fixed: number; details: string[] }; + + expect(result.success).toBe(true); + expect(result.fixed).toBe(1); + expect(result.details[0]).toContain("Unblocked"); + + const updated = await kv.get(KV.actions, blocked.id); + expect(updated!.status).toBe("pending"); + }); + + it("blocks pending action with unsatisfied deps", async () => { + const dep = makeAction({ status: "active" }); + const pending = makeAction({ + status: "pending", + title: "Should be blocked", + }); + const edge = makeEdge({ + sourceActionId: pending.id, + targetActionId: dep.id, + type: "requires", + }); + await kv.set(KV.actions, dep.id, dep); + await kv.set(KV.actions, pending.id, pending); + await kv.set(KV.actionEdges, edge.id, edge); + + const result = (await sdk.trigger("mem::heal", { + categories: ["actions"], + })) as { success: boolean; fixed: number; details: string[] }; + + expect(result.success).toBe(true); + expect(result.fixed).toBe(1); + expect(result.details[0]).toContain("Blocked"); + + const updated = await kv.get(KV.actions, pending.id); + expect(updated!.status).toBe("blocked"); + }); + + it("expires stale lease and resets action", async () => { + const action = makeAction({ + status: "active", + assignedTo: "agent-1", + }); + const lease = makeLease({ + actionId: action.id, + agentId: "agent-1", + status: "active", + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + await kv.set(KV.actions, action.id, action); + await kv.set(KV.leases, lease.id, lease); + + const result = (await sdk.trigger("mem::heal", { + categories: ["leases"], + })) as { success: boolean; fixed: number; details: string[] }; + + expect(result.success).toBe(true); + expect(result.fixed).toBe(1); + expect(result.details[0]).toContain("Expired lease"); + + const updatedLease = await kv.get(KV.leases, lease.id); + expect(updatedLease!.status).toBe("expired"); + + const updatedAction = await kv.get(KV.actions, action.id); + expect(updatedAction!.status).toBe("pending"); + expect(updatedAction!.assignedTo).toBeUndefined(); + }); + + it("deletes orphaned lease", async () => { + const lease = makeLease({ + actionId: "act_gone", + status: "released", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + await kv.set(KV.leases, lease.id, lease); + + const result = (await sdk.trigger("mem::heal", { + categories: ["leases"], + })) as { success: boolean; fixed: number; details: string[] }; + + expect(result.success).toBe(true); + expect(result.fixed).toBe(1); + expect(result.details[0]).toContain("Deleted orphaned lease"); + + const deleted = await kv.get(KV.leases, lease.id); + expect(deleted).toBeNull(); + }); + + it("expires stale sentinel", async () => { + const sentinel = makeSentinel({ + status: "watching", + name: "Stale watcher", + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }); + await kv.set(KV.sentinels, sentinel.id, sentinel); + + const result = (await sdk.trigger("mem::heal", { + categories: ["sentinels"], + })) as { success: boolean; fixed: number; details: string[] }; + + expect(result.success).toBe(true); + expect(result.fixed).toBe(1); + expect(result.details[0]).toContain("Expired sentinel"); + + const updated = await kv.get(KV.sentinels, sentinel.id); + expect(updated!.status).toBe("expired"); + }); + + it("dry run reports but does not fix", async () => { + const dep = makeAction({ status: "done" }); + const blocked = makeAction({ status: "blocked", title: "Stuck task" }); + const edge = makeEdge({ + sourceActionId: blocked.id, + targetActionId: dep.id, + type: "requires", + }); + await kv.set(KV.actions, dep.id, dep); + await kv.set(KV.actions, blocked.id, blocked); + await kv.set(KV.actionEdges, edge.id, edge); + + const result = (await sdk.trigger("mem::heal", { + categories: ["actions"], + dryRun: true, + })) as { success: boolean; fixed: number; details: string[] }; + + expect(result.success).toBe(true); + expect(result.fixed).toBe(1); + expect(result.details[0]).toContain("[dry-run]"); + + const unchanged = await kv.get(KV.actions, blocked.id); + expect(unchanged!.status).toBe("blocked"); + }); + }); + + describe("per-store tally categories (#lesson-visibility)", () => { + it("lessons category: passes with valid live lessons + ignores tombstoned", async () => { + await kv.set(KV.lessons, "lsn_live", { + id: "lsn_live", content: "x", context: "", confidence: 0.8, + reinforcements: 0, source: "manual", sourceIds: [], tags: [], + createdAt: "", updatedAt: "", decayRate: 0.05, + }); + await kv.set(KV.lessons, "lsn_tomb", { + id: "lsn_tomb", content: "x", context: "", confidence: 0.5, + reinforcements: 0, source: "manual", sourceIds: [], tags: [], + createdAt: "", updatedAt: "", decayRate: 0.05, deleted: true, + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["lessons"], + })) as { checks: DiagnosticCheck[] }; + + const ok = result.checks.find((c) => c.name === "lessons-ok"); + expect(ok?.status).toBe("pass"); + expect(ok?.message).toMatch(/All 1 lessons.*1 tombstoned/); + }); + + it("lessons category: warns on out-of-range confidence", async () => { + await kv.set(KV.lessons, "lsn_bad", { + id: "lsn_bad", content: "x", context: "", confidence: 1.5, + reinforcements: 0, source: "manual", sourceIds: [], tags: [], + createdAt: "", updatedAt: "", decayRate: 0.05, + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["lessons"], + })) as { checks: DiagnosticCheck[] }; + + const warn = result.checks.find((c) => c.name.startsWith("lesson-bad-confidence:")); + expect(warn?.status).toBe("warn"); + }); + + it("summaries category: warns on missing title", async () => { + await kv.set(KV.summaries, "ses_1", { + sessionId: "ses_1", project: "p", createdAt: "", title: "", + narrative: "n", keyDecisions: [], filesModified: [], concepts: [], + observationCount: 1, + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["summaries"], + })) as { checks: DiagnosticCheck[] }; + + const warn = result.checks.find((c) => c.name.startsWith("summary-missing-title:")); + expect(warn?.status).toBe("warn"); + }); + + it("procedural category: warns on empty steps", async () => { + await kv.set(KV.procedural, "proc_1", { + id: "proc_1", name: "noop", steps: [], triggerCondition: "x", + frequency: 1, sourceSessionIds: [], strength: 0.5, + createdAt: "", updatedAt: "", + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["procedural"], + })) as { checks: DiagnosticCheck[] }; + + const warn = result.checks.find((c) => c.name.startsWith("procedural-empty-steps:")); + expect(warn?.status).toBe("warn"); + }); + + it("crystals category: warns on empty narrative", async () => { + await kv.set(KV.crystals, "cry_1", { + id: "cry_1", narrative: "", keyOutcomes: [], filesAffected: [], + lessons: [], sourceActionIds: [], createdAt: "", + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["crystals"], + })) as { checks: DiagnosticCheck[] }; + + const warn = result.checks.find((c) => c.name.startsWith("crystal-empty-narrative:")); + expect(warn?.status).toBe("warn"); + }); + + it("insights category: warns on out-of-range confidence", async () => { + await kv.set(KV.insights, "ins_bad", { + id: "ins_bad", title: "t", content: "c", confidence: -0.1, + reinforcements: 0, sourceConceptCluster: [], sourceMemoryIds: [], + sourceLessonIds: [], sourceCrystalIds: [], tags: [], + createdAt: "", updatedAt: "", decayRate: 0.05, + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["insights"], + })) as { checks: DiagnosticCheck[] }; + + const warn = result.checks.find((c) => c.name.startsWith("insight-bad-confidence:")); + expect(warn?.status).toBe("warn"); + }); + + it("semantic category: warns on out-of-range confidence", async () => { + await kv.set(KV.semantic, "sem_bad", { + id: "sem_bad", fact: "f", confidence: 2.0, sourceSessionIds: [], + sourceMemoryIds: [], accessCount: 0, lastAccessedAt: "", + strength: 0, createdAt: "", updatedAt: "", + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["semantic"], + })) as { checks: DiagnosticCheck[] }; + + const warn = result.checks.find((c) => c.name.startsWith("semantic-bad-confidence:")); + expect(warn?.status).toBe("warn"); + }); + + it("categories filter accepts new categories and skips others", async () => { + const result = (await sdk.trigger("mem::diagnose", { + categories: ["lessons", "summaries"], + })) as { checks: DiagnosticCheck[] }; + + expect(result.checks.every((c) => c.category === "lessons" || c.category === "summaries")).toBe(true); + expect(result.checks.some((c) => c.category === "lessons")).toBe(true); + expect(result.checks.some((c) => c.category === "summaries")).toBe(true); + }); + + describe("defensive row-shape handling (CodeRabbit #473 review)", () => { + it("NaN/Infinity confidence on a lesson is flagged as warn, not silently passed", async () => { + await kv.set(KV.lessons, "lsn_nan", { + id: "lsn_nan", content: "x", context: "", confidence: NaN, + reinforcements: 0, source: "manual", sourceIds: [], tags: [], + createdAt: "", updatedAt: "", decayRate: 0.05, + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["lessons"], + })) as { checks: DiagnosticCheck[] }; + + const warn = result.checks.find((c) => c.name.startsWith("lesson-bad-confidence:")); + expect(warn?.status).toBe("warn"); + }); + + it("non-string summary title doesn't throw — surfaces as warn", async () => { + await kv.set(KV.summaries, "ses_bad_title", { + sessionId: "ses_bad_title", + project: "p", + createdAt: "", + title: null as unknown as string, // simulate corrupted row + narrative: "n", + keyDecisions: [], + filesModified: [], + concepts: [], + observationCount: 1, + }); + + // The bug to guard against: the old code called .trim() unconditionally, + // which throws on null/number, which aborts the whole diagnose run and + // any later category check never executes. Verify diagnose completes + // AND surfaces the bad row. + const result = (await sdk.trigger("mem::diagnose", { + categories: ["summaries", "lessons"], + })) as { checks: DiagnosticCheck[]; success?: boolean }; + + expect(result.success).toBe(true); + const warn = result.checks.find((c) => c.name.startsWith("summary-missing-title:")); + expect(warn?.status).toBe("warn"); + // Later category still ran: + expect(result.checks.some((c) => c.category === "lessons")).toBe(true); + }); + + it("non-string crystal narrative doesn't throw — surfaces as warn", async () => { + await kv.set(KV.crystals, "cry_bad", { + id: "cry_bad", + narrative: undefined as unknown as string, + keyOutcomes: [], + filesAffected: [], + lessons: [], + sourceActionIds: [], + createdAt: "", + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["crystals"], + })) as { checks: DiagnosticCheck[]; success?: boolean }; + + expect(result.success).toBe(true); + const warn = result.checks.find((c) => c.name.startsWith("crystal-empty-narrative:")); + expect(warn?.status).toBe("warn"); + }); + + it("Infinity confidence on insight + semantic both flagged", async () => { + await kv.set(KV.insights, "ins_inf", { + id: "ins_inf", + title: "t", + content: "c", + confidence: Infinity, + reinforcements: 0, + sourceConceptCluster: [], + sourceMemoryIds: [], + sourceLessonIds: [], + sourceCrystalIds: [], + tags: [], + createdAt: "", + updatedAt: "", + decayRate: 0.05, + }); + await kv.set(KV.semantic, "sem_nan", { + id: "sem_nan", + fact: "f", + confidence: NaN, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 0, + lastAccessedAt: "", + strength: 0, + createdAt: "", + updatedAt: "", + }); + + const result = (await sdk.trigger("mem::diagnose", { + categories: ["insights", "semantic"], + })) as { checks: DiagnosticCheck[] }; + + expect(result.checks.find((c) => c.name === "insight-bad-confidence:ins_inf")?.status).toBe("warn"); + expect(result.checks.find((c) => c.name === "semantic-bad-confidence:sem_nan")?.status).toBe("warn"); + }); + }); + }); +}); diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts new file mode 100644 index 0000000..6c2d263 --- /dev/null +++ b/test/embedding-provider.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + createEmbeddingProvider, + withDimensionGuard, +} from "../src/providers/embedding/index.js"; +import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js"; +import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; +import type { EmbeddingProvider } from "../src/types.js"; + +describe("createEmbeddingProvider", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env["GEMINI_API_KEY"]; + delete process.env["OPENAI_API_KEY"]; + delete process.env["VOYAGE_API_KEY"]; + delete process.env["COHERE_API_KEY"]; + delete process.env["OPENROUTER_API_KEY"]; + delete process.env["EMBEDDING_PROVIDER"]; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("returns null when no API keys are set", () => { + const provider = createEmbeddingProvider(); + expect(provider).toBeNull(); + }); + + it("returns GeminiEmbeddingProvider when GEMINI_API_KEY is set", () => { + process.env["GEMINI_API_KEY"] = "test-key-123"; + const provider = createEmbeddingProvider(); + expect(provider).toBeInstanceOf(GeminiEmbeddingProvider); + expect(provider!.name).toBe("gemini"); + }); + + it("returns OpenAIEmbeddingProvider when OPENAI_API_KEY is set", () => { + process.env["OPENAI_API_KEY"] = "test-key-456"; + const provider = createEmbeddingProvider(); + expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); + expect(provider!.name).toBe("openai"); + }); + + it("EMBEDDING_PROVIDER override takes precedence", () => { + process.env["GEMINI_API_KEY"] = "test-key-123"; + process.env["OPENAI_API_KEY"] = "test-key-456"; + process.env["EMBEDDING_PROVIDER"] = "openai"; + const provider = createEmbeddingProvider(); + expect(provider).toBeInstanceOf(OpenAIEmbeddingProvider); + }); +}); + +describe("OpenAIEmbeddingProvider", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env["OPENAI_BASE_URL"]; + delete process.env["OPENAI_EMBEDDING_BASE_URL"]; + delete process.env["OPENAI_EMBEDDING_API_KEY"]; + delete process.env["OPENAI_EMBEDDING_MODEL"]; + delete process.env["OPENAI_EMBEDDING_DIMENSIONS"]; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("uses default base URL and model when env vars are not set", () => { + const provider = new OpenAIEmbeddingProvider("test-key"); + expect(provider.name).toBe("openai"); + expect(provider.dimensions).toBe(1536); + }); + + it("throws when no API key is provided", () => { + delete process.env["OPENAI_API_KEY"]; + delete process.env["OPENAI_EMBEDDING_API_KEY"]; + expect(() => new OpenAIEmbeddingProvider()).toThrow(/API key is required.*OPENAI_EMBEDDING_API_KEY.*OPENAI_API_KEY/); + }); + + it("respects OPENAI_BASE_URL env var", async () => { + process.env["OPENAI_BASE_URL"] = "https://my-proxy.example.com"; + const provider = new OpenAIEmbeddingProvider("test-key"); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), { status: 200 }), + ); + + await provider.embed("hello"); + expect(fetchSpy).toHaveBeenCalledWith( + "https://my-proxy.example.com/v1/embeddings", + expect.any(Object), + ); + + fetchSpy.mockRestore(); + }); + + it("respects OPENAI_EMBEDDING_MODEL env var", async () => { + process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-3-large"; + const provider = new OpenAIEmbeddingProvider("test-key"); + + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), { status: 200 }), + ); + + await provider.embed("hello"); + const body = JSON.parse((fetchSpy.mock.calls[0][1] as RequestInit).body as string); + expect(body.model).toBe("text-embedding-3-large"); + + fetchSpy.mockRestore(); + }); + + it("derives dimensions from model in the known-models table", () => { + process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-3-large"; + const large = new OpenAIEmbeddingProvider("test-key"); + expect(large.dimensions).toBe(3072); + + process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-ada-002"; + const ada = new OpenAIEmbeddingProvider("test-key"); + expect(ada.dimensions).toBe(1536); + + process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-3-small"; + const small = new OpenAIEmbeddingProvider("test-key"); + expect(small.dimensions).toBe(1536); + }); + + it("OPENAI_EMBEDDING_DIMENSIONS overrides the model-derived dimensions", () => { + process.env["OPENAI_EMBEDDING_MODEL"] = "text-embedding-3-large"; + process.env["OPENAI_EMBEDDING_DIMENSIONS"] = "768"; + const provider = new OpenAIEmbeddingProvider("test-key"); + expect(provider.dimensions).toBe(768); + }); + + it("falls back to 1536 for unknown custom models", () => { + process.env["OPENAI_EMBEDDING_MODEL"] = "mystery-self-hosted-model"; + const provider = new OpenAIEmbeddingProvider("test-key"); + expect(provider.dimensions).toBe(1536); + }); + + it("rejects invalid OPENAI_EMBEDDING_DIMENSIONS values", () => { + process.env["OPENAI_EMBEDDING_DIMENSIONS"] = "not-a-number"; + expect(() => new OpenAIEmbeddingProvider("test-key")).toThrow( + /OPENAI_EMBEDDING_DIMENSIONS must be a positive integer/, + ); + + process.env["OPENAI_EMBEDDING_DIMENSIONS"] = "-5"; + expect(() => new OpenAIEmbeddingProvider("test-key")).toThrow( + /OPENAI_EMBEDDING_DIMENSIONS must be a positive integer/, + ); + + process.env["OPENAI_EMBEDDING_DIMENSIONS"] = "0"; + expect(() => new OpenAIEmbeddingProvider("test-key")).toThrow( + /OPENAI_EMBEDDING_DIMENSIONS must be a positive integer/, + ); + }); +}); + +describe("withDimensionGuard", () => { + function fakeProvider(opts: { + dimensions: number; + embed: () => Float32Array; + batch?: () => Float32Array[]; + image?: () => Float32Array; + }): EmbeddingProvider { + const provider: EmbeddingProvider = { + name: "fake", + dimensions: opts.dimensions, + embed: async () => opts.embed(), + embedBatch: async () => opts.batch?.() ?? [opts.embed()], + }; + if (opts.image) provider.embedImage = async () => opts.image!(); + return provider; + } + + it("preserves the wrapped provider's prototype so instanceof keeps working", async () => { + class FakeProvider implements EmbeddingProvider { + readonly name = "fake-class"; + readonly dimensions = 4; + async embed(): Promise { + return new Float32Array([1, 2, 3, 4]); + } + async embedBatch(): Promise { + return [new Float32Array([1, 2, 3, 4])]; + } + } + const guarded = withDimensionGuard(new FakeProvider()); + expect(guarded).toBeInstanceOf(FakeProvider); + expect(guarded.name).toBe("fake-class"); + expect(guarded.dimensions).toBe(4); + }); + + it("passes through vectors that match the declared dimensions", async () => { + const guarded = withDimensionGuard( + fakeProvider({ + dimensions: 4, + embed: () => new Float32Array([1, 2, 3, 4]), + batch: () => [new Float32Array([1, 2, 3, 4]), new Float32Array([5, 6, 7, 8])], + }), + ); + await expect(guarded.embed("x")).resolves.toEqual(new Float32Array([1, 2, 3, 4])); + await expect(guarded.embedBatch(["a", "b"])).resolves.toHaveLength(2); + }); + + it("throws when embed() returns the wrong dimension", async () => { + const guarded = withDimensionGuard( + fakeProvider({ + dimensions: 4, + embed: () => new Float32Array([1, 2, 3]), + }), + ); + await expect(guarded.embed("x")).rejects.toThrow( + /dimension mismatch in fake\.embed: expected 4, got 3/, + ); + }); + + it("throws when any vector in embedBatch() returns the wrong dimension", async () => { + const guarded = withDimensionGuard( + fakeProvider({ + dimensions: 4, + embed: () => new Float32Array([1, 2, 3, 4]), + batch: () => [new Float32Array([1, 2, 3, 4]), new Float32Array([1, 2])], + }), + ); + await expect(guarded.embedBatch(["a", "b"])).rejects.toThrow( + /dimension mismatch in fake\.embedBatch\[1\]: expected 4, got 2/, + ); + }); + + it("guards embedImage when present and omits it when absent", async () => { + const withImage = withDimensionGuard( + fakeProvider({ + dimensions: 4, + embed: () => new Float32Array([1, 2, 3, 4]), + image: () => new Float32Array([1, 2]), + }), + ); + expect(withImage.embedImage).toBeDefined(); + await expect(withImage.embedImage!("/tmp/x")).rejects.toThrow( + /dimension mismatch in fake\.embedImage: expected 4, got 2/, + ); + + const withoutImage = withDimensionGuard( + fakeProvider({ + dimensions: 4, + embed: () => new Float32Array([1, 2, 3, 4]), + }), + ); + expect(withoutImage.embedImage).toBeUndefined(); + }); +}); diff --git a/test/enrich-project-isolation.test.ts b/test/enrich-project-isolation.test.ts new file mode 100644 index 0000000..40016f1 --- /dev/null +++ b/test/enrich-project-isolation.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerEnrichFunction } from "../src/functions/enrich.js"; +import type { Memory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : (idOrInput as { payload: unknown }).payload; + if (triggerOverrides.has(id)) return triggerOverrides.get(id)!(payload); + const fn = functions.get(id); + if (!fn) throw new Error(`No function registered: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + }; +} + +function makeBugMemory(overrides: Partial = {}): Memory { + return { + id: "mem_bug_1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "bug", + title: "express-jwt whitespace bug", + content: "express-jwt throws 401 when Authorization header has extra whitespace after Bearer", + concepts: ["auth", "jwt"], + files: ["src/middleware/auth.ts"], + sessionIds: ["sess-api-001"], + strength: 8, + version: 1, + isLatest: true, + ...overrides, + }; +} + +describe("mem::enrich — project isolation for bug memories", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerEnrichFunction(sdk as never, kv as never); + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + sdk.overrideTrigger("mem::search", async () => ({ results: [] })); + }); + + it("does not surface a scoped bug memory when caller project differs", async () => { + await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: "api" })); + + const result = await sdk.trigger("mem::enrich", { + sessionId: "sess-web-001", + files: ["src/middleware/auth.ts"], + project: "web", + }) as { context: string }; + + expect(result.context).not.toContain("agentmemory-past-errors"); + expect(result.context).not.toContain("express-jwt"); + }); + + it("surfaces a scoped bug memory when caller project matches", async () => { + await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: "api" })); + + const result = await sdk.trigger("mem::enrich", { + sessionId: "sess-api-001", + files: ["src/middleware/auth.ts"], + project: "api", + }) as { context: string }; + + expect(result.context).toContain("agentmemory-past-errors"); + expect(result.context).toContain("express-jwt"); + }); + + it("surfaces an unscoped (legacy) bug memory regardless of caller project", async () => { + await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: undefined })); + + const result = await sdk.trigger("mem::enrich", { + sessionId: "sess-web-001", + files: ["src/middleware/auth.ts"], + project: "web", + }) as { context: string }; + + // Unscoped memories remain visible everywhere for backward-compat + expect(result.context).toContain("agentmemory-past-errors"); + expect(result.context).toContain("express-jwt"); + }); + + it("surfaces an unscoped bug memory when caller provides no project", async () => { + await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: undefined })); + + const result = await sdk.trigger("mem::enrich", { + sessionId: "sess-api-001", + files: ["src/middleware/auth.ts"], + }) as { context: string }; + + expect(result.context).toContain("agentmemory-past-errors"); + }); + + it("surfaces a scoped bug memory when caller provides no project", async () => { + await kv.set("mem:memories", "mem_bug_1", makeBugMemory({ project: "api" })); + + // No project on the caller — guard does not engage, memory is visible + const result = await sdk.trigger("mem::enrich", { + sessionId: "sess-api-001", + files: ["src/middleware/auth.ts"], + }) as { context: string }; + + expect(result.context).toContain("agentmemory-past-errors"); + }); + + it("isolates multiple memories from different projects correctly", async () => { + await kv.set("mem:memories", "mem_api", makeBugMemory({ + id: "mem_api", + project: "api", + title: "express-jwt whitespace", + content: "express-jwt whitespace issue", + files: ["src/middleware/auth.ts"], + })); + await kv.set("mem:memories", "mem_web", makeBugMemory({ + id: "mem_web", + project: "web", + title: "nextauth cookie", + content: "nextauth cookie domain mismatch", + files: ["src/middleware/auth.ts"], + })); + + const apiResult = await sdk.trigger("mem::enrich", { + sessionId: "sess-api-001", + files: ["src/middleware/auth.ts"], + project: "api", + }) as { context: string }; + + expect(apiResult.context).toContain("express-jwt"); + expect(apiResult.context).not.toContain("nextauth"); + + const webResult = await sdk.trigger("mem::enrich", { + sessionId: "sess-web-001", + files: ["src/middleware/auth.ts"], + project: "web", + }) as { context: string }; + + expect(webResult.context).toContain("nextauth"); + expect(webResult.context).not.toContain("express-jwt"); + }); + + it("only includes latest bug memories, respecting project scope", async () => { + await kv.set("mem:memories", "mem_old", makeBugMemory({ + id: "mem_old", + project: "api", + title: "old express bug", + content: "old express auth bug now fixed", + files: ["src/middleware/auth.ts"], + isLatest: false, + })); + await kv.set("mem:memories", "mem_new", makeBugMemory({ + id: "mem_new", + project: "api", + title: "new express bug", + content: "new express auth edge case", + files: ["src/middleware/auth.ts"], + isLatest: true, + })); + + const result = await sdk.trigger("mem::enrich", { + sessionId: "sess-api-001", + files: ["src/middleware/auth.ts"], + project: "api", + }) as { context: string }; + + expect(result.context).toContain("new express bug"); + expect(result.context).not.toContain("old express auth bug"); + }); +}); + +describe("mem::enrich — project forwarded to mem::search", () => { + it("passes project to the search trigger when provided", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerEnrichFunction(sdk as never, kv as never); + + let capturedSearchPayload: Record = {}; + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + sdk.overrideTrigger("mem::search", async (payload: unknown) => { + capturedSearchPayload = payload as Record; + return { results: [] }; + }); + + await sdk.trigger("mem::enrich", { + sessionId: "sess-api-001", + files: ["src/middleware/auth.ts"], + project: "api", + }); + + expect(capturedSearchPayload.project).toBe("api"); + }); + + it("does not pass project to search when caller provides none", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerEnrichFunction(sdk as never, kv as never); + + let capturedSearchPayload: Record = {}; + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + sdk.overrideTrigger("mem::search", async (payload: unknown) => { + capturedSearchPayload = payload as Record; + return { results: [] }; + }); + + await sdk.trigger("mem::enrich", { + sessionId: "sess-api-001", + files: ["src/middleware/auth.ts"], + }); + + expect(capturedSearchPayload.project).toBeUndefined(); + }); +}); diff --git a/test/enrich.test.ts b/test/enrich.test.ts new file mode 100644 index 0000000..adfb31f --- /dev/null +++ b/test/enrich.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerEnrichFunction } from "../src/functions/enrich.js"; +import type { Memory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeMemory(overrides: Partial = {}): Memory { + return { + id: "mem_1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "bug", + title: "Known bug", + content: "Null pointer in handler", + concepts: ["bug"], + files: ["src/handler.ts"], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + ...overrides, + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + if (triggerOverrides.has(id)) { + return triggerOverrides.get(id)!(payload); + } + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +describe("Enrich Function", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerEnrichFunction(sdk as never, kv as never); + }); + + it("returns file context and relevant memories", async () => { + sdk.overrideTrigger( + "mem::file-context", + async () => ({ context: "File was edited in session ses_1" }), + ); + sdk.overrideTrigger("mem::search", async () => ({ + results: [ + { observation: { narrative: "User fixed a bug in handler" } }, + ], + })); + + const bugMem = makeMemory({ + id: "bug_1", + files: ["src/handler.ts"], + type: "bug", + }); + await kv.set("mem:memories", "bug_1", bugMem); + + const result = (await sdk.trigger("mem::enrich", { + sessionId: "ses_1", + files: ["src/handler.ts"], + })) as { context: string; truncated: boolean }; + + expect(result.context).toContain("File was edited in session ses_1"); + expect(result.context).toContain("agentmemory-relevant-context"); + expect(result.context).toContain("agentmemory-past-errors"); + expect(result.truncated).toBe(false); + }); + + it("extracts terms from Grep/Glob pattern for search", async () => { + let capturedQuery = ""; + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + sdk.overrideTrigger("mem::search", async (data: any) => { + capturedQuery = data.query; + return { results: [] }; + }); + + await sdk.trigger("mem::enrich", { + sessionId: "ses_1", + files: ["src/utils.ts"], + terms: ["handleError"], + toolName: "Grep", + }); + + expect(capturedQuery).toContain("handleError"); + }); + + it("truncates context at 4000 chars", async () => { + const longContext = "x".repeat(5000); + sdk.overrideTrigger( + "mem::file-context", + async () => ({ context: longContext }), + ); + sdk.overrideTrigger("mem::search", async () => ({ results: [] })); + + const result = (await sdk.trigger("mem::enrich", { + sessionId: "ses_1", + files: ["src/big.ts"], + })) as { context: string; truncated: boolean }; + + expect(result.context.length).toBe(4000); + expect(result.truncated).toBe(true); + }); + + it("returns empty context when no data found", async () => { + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + sdk.overrideTrigger("mem::search", async () => ({ results: [] })); + + const result = (await sdk.trigger("mem::enrich", { + sessionId: "ses_1", + files: ["src/new-file.ts"], + })) as { context: string; truncated: boolean }; + + expect(result.context).toBe(""); + expect(result.truncated).toBe(false); + }); + + it("handles failed triggers without crashing", async () => { + sdk.overrideTrigger("mem::file-context", async () => { + throw new Error("file-context failed"); + }); + sdk.overrideTrigger("mem::search", async () => { + throw new Error("search failed"); + }); + + const result = (await sdk.trigger("mem::enrich", { + sessionId: "ses_1", + files: ["src/handler.ts"], + })) as { context: string; truncated: boolean }; + + expect(result.context).toBeDefined(); + expect(result.truncated).toBe(false); + }); + + it("includes bug memories that overlap with requested files", async () => { + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + sdk.overrideTrigger("mem::search", async () => ({ results: [] })); + + const bugMem = makeMemory({ + id: "bug_match", + type: "bug", + title: "Race condition", + content: "Race condition in worker pool", + files: ["src/worker.ts"], + isLatest: true, + }); + const nonBugMem = makeMemory({ + id: "pattern_1", + type: "pattern", + title: "Code pattern", + content: "Singleton pattern used", + files: ["src/worker.ts"], + isLatest: true, + }); + await kv.set("mem:memories", "bug_match", bugMem); + await kv.set("mem:memories", "pattern_1", nonBugMem); + + const result = (await sdk.trigger("mem::enrich", { + sessionId: "ses_1", + files: ["src/worker.ts"], + })) as { context: string; truncated: boolean }; + + expect(result.context).toContain("Race condition"); + expect(result.context).not.toContain("Singleton pattern"); + }); +}); diff --git a/test/env-loader.test.ts b/test/env-loader.test.ts new file mode 100644 index 0000000..17ff6a8 --- /dev/null +++ b/test/env-loader.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; + +let sandboxHome: string; + +async function freshConfig() { + vi.resetModules(); + return await import("../src/config.js"); +} + +function writeEnv(contents: string) { + const dir = join(sandboxHome, ".agentmemory"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, ".env"), contents); +} + +describe("loadEnvFile", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-env-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + delete process.env["AGENTMEMORY_AUTO_COMPRESS"]; + delete process.env["AGENTMEMORY_DROP_STALE_INDEX"]; + delete process.env["CONSOLIDATION_ENABLED"]; + delete process.env["GRAPH_EXTRACTION_ENABLED"]; + delete process.env["TOKEN"]; + delete process.env["HASHVAL"]; + }); + + afterEach(() => { + if (ORIGINAL_HOME === undefined) delete process.env["HOME"]; + else process.env["HOME"] = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE; + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + it("strips trailing inline # comments on unquoted values", async () => { + writeEnv( + [ + "AGENTMEMORY_AUTO_COMPRESS=true # opt in to LLM compression", + "CONSOLIDATION_ENABLED=true # daily summarization", + "GRAPH_EXTRACTION_ENABLED=true # entity graph", + ].join("\n"), + ); + const cfg = await freshConfig(); + expect(cfg.isAutoCompressEnabled()).toBe(true); + expect(cfg.isConsolidationEnabled()).toBe(true); + expect(cfg.isGraphExtractionEnabled()).toBe(true); + }); + + it("preserves # inside double-quoted values", async () => { + writeEnv('TOKEN="abc#def"'); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("TOKEN")).toBe("abc#def"); + }); + + it("preserves # inside single-quoted values", async () => { + writeEnv("TOKEN='abc#def'"); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("TOKEN")).toBe("abc#def"); + }); + + it("treats hash without leading space as part of value", async () => { + writeEnv("HASHVAL=abc#def"); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("HASHVAL")).toBe("abc#def"); + }); + + it("strips inline comment after a quoted value and unwraps quotes", async () => { + writeEnv('TOKEN="abc" # trailing comment'); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("TOKEN")).toBe("abc"); + }); + + it("strips inline comment after a single-quoted value and unwraps quotes", async () => { + writeEnv("TOKEN='abc' # trailing comment"); + const cfg = await freshConfig(); + expect(cfg.getEnvVar("TOKEN")).toBe("abc"); + }); + + it("reads AGENTMEMORY_DROP_STALE_INDEX from the env file", async () => { + writeEnv("AGENTMEMORY_DROP_STALE_INDEX=true"); + const cfg = await freshConfig(); + expect(cfg.isDropStaleIndexEnabled()).toBe(true); + }); +}); diff --git a/test/eval-adapters.test.ts b/test/eval-adapters.test.ts new file mode 100644 index 0000000..90f914f --- /dev/null +++ b/test/eval-adapters.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { grepAdapter } from "../eval/runner/adapters/grep.js"; +import { aggregate, scoreQuestion } from "../eval/runner/score.js"; +import type { Question, Session } from "../eval/runner/types.js"; + +const DATA_DIR = resolve(__dirname, "..", "eval", "data", "coding-agent-life-v1"); +const sessions = JSON.parse(readFileSync(`${DATA_DIR}/sessions.json`, "utf8")) as Session[]; +const queries = JSON.parse(readFileSync(`${DATA_DIR}/queries.json`, "utf8")) as Array< + Omit +>; + +describe("eval scaffold", () => { + it("coding-agent-life-v1 corpus is well-formed", () => { + expect(sessions.length).toBeGreaterThan(0); + expect(queries.length).toBeGreaterThan(0); + const sessionIds = new Set(sessions.map((s) => s.id)); + for (const q of queries) { + expect(q.goldSessionIds.length).toBeGreaterThan(0); + for (const id of q.goldSessionIds) { + expect(sessionIds.has(id)).toBe(true); + } + } + }); + + it("grep adapter ranks gold session in top-5 for most queries", async () => { + const state = await grepAdapter.init(sessions); + let hits = 0; + for (const q of queries) { + const ranked = await grepAdapter.query(q.question, state, 5); + const topIds = new Set(ranked.map((r) => r.sessionId)); + if (q.goldSessionIds.some((id) => topIds.has(id))) hits += 1; + } + expect(hits / queries.length).toBeGreaterThan(0.5); + }); + + it("scoreQuestion computes P@K, R@K, hit, topGoldRank", () => { + const q: Question = { + id: "test", + type: "single-session", + question: "?", + goldSessionIds: ["a", "b"], + haystack: [], + }; + const ranked = [ + { sessionId: "x", score: 0.9 }, + { sessionId: "a", score: 0.7 }, + { sessionId: "y", score: 0.5 }, + { sessionId: "b", score: 0.3 }, + ]; + const row = scoreQuestion(q, ranked, 5, "test", 12); + expect(row.hit).toBe(true); + expect(row.recallAtK).toBe(1); + expect(row.precisionAtK).toBeCloseTo(2 / 5); + expect(row.topGoldRank).toBe(2); + }); + + it("scoreQuestion handles miss", () => { + const q: Question = { + id: "test", + type: "x", + question: "?", + goldSessionIds: ["a"], + haystack: [], + }; + const ranked = [ + { sessionId: "x", score: 1 }, + { sessionId: "y", score: 0.5 }, + ]; + const row = scoreQuestion(q, ranked, 5, "test", 5); + expect(row.hit).toBe(false); + expect(row.recallAtK).toBe(0); + expect(row.topGoldRank).toBeNull(); + }); + + it("aggregate computes per-adapter and per-type means", () => { + const q: Question = { + id: "1", + type: "t1", + question: "?", + goldSessionIds: ["a"], + haystack: [], + }; + const row1 = scoreQuestion(q, [{ sessionId: "a", score: 1 }], 5, "grep", 10); + const row2 = scoreQuestion(q, [{ sessionId: "x", score: 1 }], 5, "grep", 20); + const agg = aggregate([row1, row2]); + expect(agg.byAdapter.grep.hit).toBe(1); + expect(agg.byAdapter.grep.n).toBe(2); + expect(agg.byType.t1.grep.n).toBe(2); + }); +}); diff --git a/test/eval.test.ts b/test/eval.test.ts new file mode 100644 index 0000000..f1070e1 --- /dev/null +++ b/test/eval.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect } from "vitest"; +import { + ObserveInputSchema, + CompressOutputSchema, + SummaryOutputSchema, + SearchInputSchema, + ContextInputSchema, + RememberInputSchema, +} from "../src/eval/schemas.js"; +import { validateInput, validateOutput } from "../src/eval/validator.js"; +import { + scoreCompression, + scoreSummary, + scoreContextRelevance, +} from "../src/eval/quality.js"; + +describe("Zod Schemas", () => { + describe("ObserveInputSchema", () => { + it("accepts valid input", () => { + const result = ObserveInputSchema.safeParse({ + hookType: "post_tool_use", + sessionId: "ses_abc", + project: "my-project", + cwd: "/home/user", + timestamp: "2026-01-01T00:00:00Z", + data: { tool_name: "Read" }, + }); + expect(result.success).toBe(true); + }); + + it("rejects missing sessionId", () => { + const result = ObserveInputSchema.safeParse({ + hookType: "post_tool_use", + project: "my-project", + cwd: "/home/user", + timestamp: "2026-01-01T00:00:00Z", + data: {}, + }); + expect(result.success).toBe(false); + }); + + it("rejects invalid hookType", () => { + const result = ObserveInputSchema.safeParse({ + hookType: "invalid_hook", + sessionId: "ses_abc", + project: "my-project", + cwd: "/home/user", + timestamp: "2026-01-01T00:00:00Z", + data: {}, + }); + expect(result.success).toBe(false); + }); + }); + + describe("CompressOutputSchema", () => { + it("accepts valid output", () => { + const result = CompressOutputSchema.safeParse({ + type: "file_edit", + title: "Edit auth module", + facts: ["Added JWT validation"], + narrative: "Modified the auth middleware to validate tokens", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 7, + }); + expect(result.success).toBe(true); + }); + + it("rejects empty facts array", () => { + const result = CompressOutputSchema.safeParse({ + type: "file_edit", + title: "Edit auth module", + facts: [], + narrative: "Modified the auth middleware to validate tokens", + concepts: [], + files: [], + importance: 5, + }); + expect(result.success).toBe(false); + }); + + it("rejects title over 120 chars", () => { + const result = CompressOutputSchema.safeParse({ + type: "file_edit", + title: "x".repeat(121), + facts: ["fact"], + narrative: "A narrative that is long enough", + concepts: [], + files: [], + importance: 5, + }); + expect(result.success).toBe(false); + }); + + it("rejects importance outside 1-10", () => { + const result = CompressOutputSchema.safeParse({ + type: "file_edit", + title: "Test", + facts: ["fact"], + narrative: "A valid narrative here", + concepts: [], + files: [], + importance: 11, + }); + expect(result.success).toBe(false); + }); + + it("rejects narrative under 10 chars", () => { + const result = CompressOutputSchema.safeParse({ + type: "file_edit", + title: "Test", + facts: ["fact"], + narrative: "short", + concepts: [], + files: [], + importance: 5, + }); + expect(result.success).toBe(false); + }); + }); + + describe("SummaryOutputSchema", () => { + it("accepts valid summary", () => { + const result = SummaryOutputSchema.safeParse({ + title: "Session Summary", + narrative: "This session focused on implementing authentication features and fixing bugs", + keyDecisions: ["Use JWT"], + filesModified: ["auth.ts"], + concepts: ["auth"], + }); + expect(result.success).toBe(true); + }); + + it("rejects short narrative", () => { + const result = SummaryOutputSchema.safeParse({ + title: "Summary", + narrative: "Too short", + keyDecisions: [], + filesModified: [], + concepts: [], + }); + expect(result.success).toBe(false); + }); + }); + + describe("SearchInputSchema", () => { + it("accepts valid search", () => { + expect(SearchInputSchema.safeParse({ query: "auth" }).success).toBe(true); + }); + + it("accepts search with limit", () => { + expect( + SearchInputSchema.safeParse({ query: "auth", limit: 10 }).success, + ).toBe(true); + }); + + it("rejects empty query", () => { + expect(SearchInputSchema.safeParse({ query: "" }).success).toBe(false); + }); + }); + + describe("ContextInputSchema", () => { + it("accepts valid input", () => { + expect( + ContextInputSchema.safeParse({ + sessionId: "ses_1", + project: "proj", + }).success, + ).toBe(true); + }); + }); + + describe("RememberInputSchema", () => { + it("accepts valid input", () => { + expect( + RememberInputSchema.safeParse({ + content: "Always use TypeScript", + type: "preference", + }).success, + ).toBe(true); + }); + + it("rejects empty content", () => { + expect( + RememberInputSchema.safeParse({ content: "" }).success, + ).toBe(false); + }); + }); +}); + +describe("Validator", () => { + it("returns valid with correct data", () => { + const result = validateInput(SearchInputSchema, { query: "test" }, "search"); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.data.query).toBe("test"); + } + }); + + it("returns invalid with error details", () => { + const result = validateInput(SearchInputSchema, { query: "" }, "search"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.result.functionId).toBe("search"); + expect(result.result.errors.length).toBeGreaterThan(0); + } + }); + + it("validateOutput works same as validateInput", () => { + const result = validateOutput( + CompressOutputSchema, + { + type: "file_edit", + title: "Test", + facts: ["a"], + narrative: "A long enough narrative", + concepts: [], + files: [], + importance: 5, + }, + "compress", + ); + expect(result.valid).toBe(true); + }); +}); + +describe("Quality Scoring", () => { + describe("scoreCompression", () => { + it("returns 0 for empty object", () => { + expect(scoreCompression({})).toBe(0); + }); + + it("returns 100 for perfect observation", () => { + const score = scoreCompression({ + type: "file_edit", + title: "A good title", + facts: ["fact 1", "fact 2", "fact 3"], + narrative: "A narrative that is definitely more than fifty characters long and provides good context", + concepts: ["auth", "jwt"], + importance: 7, + }); + expect(score).toBe(100); + }); + + it("scores partial observations between 0 and 100", () => { + const score = scoreCompression({ + title: "Test", + facts: ["one"], + narrative: "Short but valid narrative", + }); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThan(100); + }); + }); + + describe("scoreSummary", () => { + it("returns 0 for empty object", () => { + expect(scoreSummary({})).toBe(0); + }); + + it("returns high score for complete summary", () => { + const score = scoreSummary({ + title: "Session Summary Title", + narrative: + "This is a detailed narrative about what happened during the session with enough content to be meaningful and complete for review purposes", + keyDecisions: ["Used JWT for auth", "Chose PostgreSQL"], + filesModified: ["src/auth.ts", "src/db.ts"], + concepts: ["authentication", "database"], + }); + expect(score).toBeGreaterThanOrEqual(90); + }); + }); + + describe("scoreContextRelevance", () => { + it("returns 0 for empty context", () => { + expect(scoreContextRelevance("", "proj")).toBe(0); + }); + + it("scores higher when project is mentioned", () => { + const withProject = scoreContextRelevance( + "This is for my-project with details", + "my-project", + ); + const without = scoreContextRelevance( + "Some generic context details", + "my-project", + ); + expect(withProject).toBeGreaterThan(without); + }); + + it("scores higher with more XML sections", () => { + const multi = scoreContextRelevance( + "ABCD", + "test", + ); + const single = scoreContextRelevance("A", "test"); + expect(multi).toBeGreaterThan(single); + }); + }); +}); diff --git a/test/evict.test.ts b/test/evict.test.ts new file mode 100644 index 0000000..d1aedac --- /dev/null +++ b/test/evict.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + CompressedObservation, + RawObservation, + Session, +} from "../src/types.js"; +import { registerEvictFunction } from "../src/functions/evict.js"; +import { KV } from "../src/state/schema.js"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +type Store = Map>; +type Handler = (payload: unknown) => unknown | Promise; + +function daysAgo(days: number): string { + return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); +} + +function makeSession(id: string): Session { + return { + id, + project: "agentmemory", + cwd: "/repo/agentmemory", + startedAt: daysAgo(31), + status: "active", + observationCount: 1, + }; +} + +function makeObservation(sessionId: string): CompressedObservation { + return { + id: "obs_1", + sessionId, + timestamp: daysAgo(31), + type: "decision", + title: "Chose sqlite storage", + facts: ["Use sqlite for local state"], + narrative: "The session chose sqlite for local state.", + concepts: ["sqlite"], + files: ["src/state/kv.ts"], + importance: 8, + }; +} + +function makeRawObservation(sessionId: string): RawObservation { + return { + id: "raw_1", + sessionId, + timestamp: daysAgo(31), + hookType: "post_tool_use", + toolName: "Edit", + raw: { file_path: "src/state/kv.ts" }, + }; +} + +function mockKV(store: Store, listFailures: Set = new Set()) { + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + if (listFailures.has(scope)) { + throw new Error(`list failed for ${scope}`); + } + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const handlers = new Map(); + const calls: Array<{ function_id: string; payload: unknown }> = []; + return { + calls, + sdk: { + registerFunction: (functionId: string, handler: Handler) => { + handlers.set(functionId, handler); + }, + trigger: async (input: { function_id: string; payload: unknown }) => { + calls.push(input); + const handler = handlers.get(input.function_id); + if (!handler) throw new Error(`missing handler: ${input.function_id}`); + return handler(input.payload); + }, + }, + }; +} + +function storeForObservations( + sessionId: string, + observations: Array, +): Store { + const session = makeSession(sessionId); + return new Map([ + [KV.sessions, new Map([[session.id, session]])], + [KV.summaries, new Map()], + [ + KV.observations(session.id), + new Map(observations.map((observation) => [observation.id, observation])), + ], + [KV.config, new Map()], + [KV.audit, new Map()], + ]); +} + +function storeForObservedSession(sessionId: string): Store { + return storeForObservations(sessionId, [makeObservation(sessionId)]); +} + +describe("mem::evict stale sessions", () => { + it("runs session recovery before deleting a stale observed session", async () => { + const sessionId = "ses_stale"; + const store = storeForObservedSession(sessionId); + const kv = mockKV(store); + const { sdk, calls } = mockSdk(); + + registerEvictFunction(sdk as never, kv as never); + sdk.registerFunction("event::session::stopped", async (payload) => { + expect(payload).toEqual({ sessionId }); + expect(await kv.get(KV.sessions, sessionId)).toMatchObject({ + id: sessionId, + }); + return { success: true }; + }); + sdk.registerFunction("mem::consolidate-pipeline", () => ({ + success: true, + })); + + const result = (await sdk.trigger({ + function_id: "mem::evict", + payload: {}, + })) as { staleSessions: number }; + + expect(result.staleSessions).toBe(1); + expect(await kv.get(KV.sessions, sessionId)).toBeNull(); + const audits = await kv.list<{ + details: { reason: string }; + }>(KV.audit); + expect(audits[0].details.reason).toBe( + "stale_session_recovered_then_evicted", + ); + expect(calls.map((call) => call.function_id)).toContain( + "event::session::stopped", + ); + expect(calls.map((call) => call.function_id)).toContain( + "mem::consolidate-pipeline", + ); + }); + + it("keeps a stale observed session when recovery fails", async () => { + const sessionId = "ses_unrecovered"; + const store = storeForObservedSession(sessionId); + const kv = mockKV(store); + const { sdk, calls } = mockSdk(); + + registerEvictFunction(sdk as never, kv as never); + sdk.registerFunction("event::session::stopped", () => ({ + success: false, + error: "no_provider", + })); + + const result = (await sdk.trigger({ + function_id: "mem::evict", + payload: {}, + })) as { staleSessions: number }; + + expect(result.staleSessions).toBe(0); + expect(await kv.get(KV.sessions, sessionId)).toMatchObject({ + id: sessionId, + }); + expect(calls.map((call) => call.function_id)).toContain( + "event::session::stopped", + ); + expect(calls.map((call) => call.function_id)).not.toContain( + "mem::consolidate-pipeline", + ); + }); + + it("keeps a stale session when observation scanning fails", async () => { + const sessionId = "ses_scan_failed"; + const store = storeForObservedSession(sessionId); + const kv = mockKV(store, new Set([KV.observations(sessionId)])); + const { sdk, calls } = mockSdk(); + + registerEvictFunction(sdk as never, kv as never); + sdk.registerFunction("event::session::stopped", () => ({ + success: true, + })); + + const result = (await sdk.trigger({ + function_id: "mem::evict", + payload: {}, + })) as { staleSessions: number }; + + expect(result.staleSessions).toBe(0); + expect(await kv.get(KV.sessions, sessionId)).toMatchObject({ + id: sessionId, + }); + expect(calls.map((call) => call.function_id)).not.toContain( + "event::session::stopped", + ); + }); + + it("keeps a stale session that only has raw observations", async () => { + const sessionId = "ses_raw_only"; + const store = storeForObservations(sessionId, [ + makeRawObservation(sessionId), + ]); + const kv = mockKV(store); + const { sdk, calls } = mockSdk(); + + registerEvictFunction(sdk as never, kv as never); + sdk.registerFunction("event::session::stopped", () => ({ + success: true, + })); + + const result = (await sdk.trigger({ + function_id: "mem::evict", + payload: {}, + })) as { staleSessions: number }; + + expect(result.staleSessions).toBe(0); + expect(await kv.get(KV.sessions, sessionId)).toMatchObject({ + id: sessionId, + }); + expect(calls.map((call) => call.function_id)).not.toContain( + "event::session::stopped", + ); + }); +}); diff --git a/test/export-import.test.ts b/test/export-import.test.ts new file mode 100644 index 0000000..3949862 --- /dev/null +++ b/test/export-import.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerExportImportFunction } from "../src/functions/export-import.js"; +import type { + Session, + CompressedObservation, + Memory, + SessionSummary, + ExportData, +} from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +const testSession: Session = { + id: "ses_1", + project: "my-project", + cwd: "/tmp", + startedAt: "2026-02-01T00:00:00Z", + status: "completed", + observationCount: 1, +}; + +const testObs: CompressedObservation = { + id: "obs_1", + sessionId: "ses_1", + timestamp: "2026-02-01T10:00:00Z", + type: "file_edit", + title: "Edit auth", + facts: ["Added check"], + narrative: "Auth changes", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 7, +}; + +const testMemory: Memory = { + id: "mem_1", + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type: "pattern", + title: "Auth pattern", + content: "Always validate tokens", + concepts: ["auth"], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, +}; + +const testSummary: SessionSummary = { + sessionId: "ses_1", + project: "my-project", + createdAt: "2026-02-01T00:00:00Z", + title: "Auth work", + narrative: "Worked on auth", + keyDecisions: ["Use JWT"], + filesModified: ["src/auth.ts"], + concepts: ["auth"], + observationCount: 1, +}; + +describe("Export/Import Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerExportImportFunction(sdk as never, kv as never); + + await kv.set("mem:sessions", "ses_1", testSession); + await kv.set("mem:obs:ses_1", "obs_1", testObs); + await kv.set("mem:memories", "mem_1", testMemory); + await kv.set("mem:summaries", "ses_1", testSummary); + }); + + it("export produces valid ExportData structure", async () => { + const result = (await sdk.trigger("mem::export", {})) as ExportData; + + expect(result.version).toBe("0.9.27"); + expect(result.exportedAt).toBeDefined(); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].id).toBe("ses_1"); + expect(result.observations["ses_1"].length).toBe(1); + expect(result.memories.length).toBe(1); + expect(result.summaries.length).toBe(1); + }); + + it("import with merge strategy adds data", async () => { + const exportData: ExportData = { + version: "0.3.0", + exportedAt: new Date().toISOString(), + sessions: [{ ...testSession, id: "ses_2", observationCount: 0 }], + observations: {}, + memories: [{ ...testMemory, id: "mem_2", title: "New pattern" }], + summaries: [], + }; + + const result = (await sdk.trigger("mem::import", { + exportData, + strategy: "merge", + })) as { success: boolean; sessions: number; memories: number }; + + expect(result.success).toBe(true); + expect(result.sessions).toBe(1); + expect(result.memories).toBe(1); + + const allSessions = await kv.list("mem:sessions"); + expect(allSessions.length).toBe(2); + }); + + it("import with skip strategy does not overwrite existing", async () => { + const exportData: ExportData = { + version: "0.3.0", + exportedAt: new Date().toISOString(), + sessions: [testSession], + observations: { ses_1: [testObs] }, + memories: [testMemory], + summaries: [testSummary], + }; + + const result = (await sdk.trigger("mem::import", { + exportData, + strategy: "skip", + })) as { success: boolean; skipped: number; sessions: number }; + + expect(result.success).toBe(true); + expect(result.skipped).toBeGreaterThan(0); + expect(result.sessions).toBe(0); + }); + + it("import with replace strategy clears existing data first", async () => { + const newSession: Session = { + id: "ses_new", + project: "new-project", + cwd: "/tmp/new", + startedAt: "2026-03-01T00:00:00Z", + status: "active", + observationCount: 0, + }; + const exportData: ExportData = { + version: "0.3.0", + exportedAt: new Date().toISOString(), + sessions: [newSession], + observations: {}, + memories: [], + summaries: [], + }; + + const result = (await sdk.trigger("mem::import", { + exportData, + strategy: "replace", + })) as { success: boolean; sessions: number }; + + expect(result.success).toBe(true); + expect(result.sessions).toBe(1); + + const oldSession = await kv.get("mem:sessions", "ses_1"); + expect(oldSession).toBeNull(); + }); + + it("export then import round-trip preserves data", async () => { + const exported = (await sdk.trigger("mem::export", {})) as ExportData; + + const freshKv = mockKV(); + const freshSdk = mockSdk(); + registerExportImportFunction(freshSdk as never, freshKv as never); + + const importResult = (await freshSdk.trigger("mem::import", { + exportData: exported, + strategy: "merge", + })) as { + success: boolean; + sessions: number; + observations: number; + memories: number; + }; + + expect(importResult.success).toBe(true); + expect(importResult.sessions).toBe(1); + expect(importResult.observations).toBe(1); + expect(importResult.memories).toBe(1); + + const reExported = (await freshSdk.trigger( + "mem::export", + {}, + )) as ExportData; + expect(reExported.sessions.length).toBe(exported.sessions.length); + expect(reExported.memories.length).toBe(exported.memories.length); + }); + + it("import rejects unsupported version", async () => { + const exportData = { + version: "1.0.0", + exportedAt: new Date().toISOString(), + sessions: [], + observations: {}, + memories: [], + summaries: [], + } as unknown as ExportData; + + const result = (await sdk.trigger("mem::import", { + exportData, + strategy: "merge", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("Unsupported export version"); + }); +}); diff --git a/test/facets.test.ts b/test/facets.test.ts new file mode 100644 index 0000000..f37d756 --- /dev/null +++ b/test/facets.test.ts @@ -0,0 +1,449 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerFacetsFunction } from "../src/functions/facets.js"; +import type { Facet } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Facets Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerFacetsFunction(sdk as never, kv as never); + }); + + describe("mem::facet-tag", () => { + it("tags a target with a facet", async () => { + const result = (await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: "priority", + value: "high", + })) as { success: boolean; facet: Facet }; + + expect(result.success).toBe(true); + expect(result.facet.id).toMatch(/^fct_/); + expect(result.facet.targetId).toBe("act_123"); + expect(result.facet.targetType).toBe("action"); + expect(result.facet.dimension).toBe("priority"); + expect(result.facet.value).toBe("high"); + expect(result.facet.createdAt).toBeDefined(); + }); + + it("returns error when dimension is empty", async () => { + const result = (await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: " ", + value: "high", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("dimension is required"); + }); + + it("returns error when value is empty", async () => { + const result = (await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: "priority", + value: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("value is required"); + }); + + it("returns error for invalid targetType", async () => { + const result = (await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "invalid", + dimension: "priority", + value: "high", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("targetType must be one of"); + }); + + it("skips duplicate tag", async () => { + await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: "priority", + value: "high", + }); + + const result = (await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: "priority", + value: "high", + })) as { success: boolean; facet: Facet; skipped: boolean }; + + expect(result.success).toBe(true); + expect(result.skipped).toBe(true); + }); + }); + + describe("mem::facet-untag", () => { + it("removes a specific value from a dimension", async () => { + await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: "priority", + value: "high", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: "priority", + value: "urgent", + }); + + const result = (await sdk.trigger("mem::facet-untag", { + targetId: "act_123", + dimension: "priority", + value: "high", + })) as { success: boolean; removed: number }; + + expect(result.success).toBe(true); + expect(result.removed).toBe(1); + + const remaining = (await sdk.trigger("mem::facet-get", { + targetId: "act_123", + })) as { success: boolean; dimensions: Array<{ dimension: string; values: string[] }> }; + + expect(remaining.dimensions[0].values).toEqual(["urgent"]); + }); + + it("removes all values in a dimension when value is omitted", async () => { + await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: "priority", + value: "high", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_123", + targetType: "action", + dimension: "priority", + value: "urgent", + }); + + const result = (await sdk.trigger("mem::facet-untag", { + targetId: "act_123", + dimension: "priority", + })) as { success: boolean; removed: number }; + + expect(result.success).toBe(true); + expect(result.removed).toBe(2); + + const remaining = (await sdk.trigger("mem::facet-get", { + targetId: "act_123", + })) as { success: boolean; dimensions: Array<{ dimension: string; values: string[] }> }; + + expect(remaining.dimensions).toEqual([]); + }); + }); + + describe("mem::facet-query", () => { + beforeEach(async () => { + await sdk.trigger("mem::facet-tag", { + targetId: "act_1", + targetType: "action", + dimension: "priority", + value: "high", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_1", + targetType: "action", + dimension: "status", + value: "active", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_2", + targetType: "action", + dimension: "priority", + value: "low", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_2", + targetType: "action", + dimension: "status", + value: "active", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "mem_1", + targetType: "memory", + dimension: "priority", + value: "high", + }); + }); + + it("queries with matchAll (AND logic)", async () => { + const result = (await sdk.trigger("mem::facet-query", { + matchAll: ["priority:high", "status:active"], + })) as { success: boolean; results: Array<{ targetId: string }> }; + + expect(result.success).toBe(true); + expect(result.results.length).toBe(1); + expect(result.results[0].targetId).toBe("act_1"); + }); + + it("queries with matchAny (OR logic)", async () => { + const result = (await sdk.trigger("mem::facet-query", { + matchAny: ["priority:high", "priority:low"], + })) as { success: boolean; results: Array<{ targetId: string }> }; + + expect(result.success).toBe(true); + expect(result.results.length).toBe(3); + }); + + it("queries with both matchAll and matchAny", async () => { + const result = (await sdk.trigger("mem::facet-query", { + matchAll: ["status:active"], + matchAny: ["priority:high"], + })) as { success: boolean; results: Array<{ targetId: string; matchedFacets: string[] }> }; + + expect(result.success).toBe(true); + expect(result.results.length).toBe(1); + expect(result.results[0].targetId).toBe("act_1"); + expect(result.results[0].matchedFacets).toContain("status:active"); + expect(result.results[0].matchedFacets).toContain("priority:high"); + }); + + it("returns error when neither matchAll nor matchAny provided", async () => { + const result = (await sdk.trigger("mem::facet-query", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("at least one of matchAll or matchAny is required"); + }); + + it("filters by targetType", async () => { + const result = (await sdk.trigger("mem::facet-query", { + matchAny: ["priority:high"], + targetType: "memory", + })) as { success: boolean; results: Array<{ targetId: string; targetType: string }> }; + + expect(result.success).toBe(true); + expect(result.results.length).toBe(1); + expect(result.results[0].targetId).toBe("mem_1"); + expect(result.results[0].targetType).toBe("memory"); + }); + + it("respects limit", async () => { + const result = (await sdk.trigger("mem::facet-query", { + matchAny: ["priority:high", "priority:low"], + limit: 1, + })) as { success: boolean; results: Array<{ targetId: string }> }; + + expect(result.success).toBe(true); + expect(result.results.length).toBe(1); + }); + }); + + describe("mem::facet-get", () => { + it("returns facets grouped by dimension", async () => { + await sdk.trigger("mem::facet-tag", { + targetId: "act_1", + targetType: "action", + dimension: "priority", + value: "high", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_1", + targetType: "action", + dimension: "priority", + value: "urgent", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_1", + targetType: "action", + dimension: "status", + value: "active", + }); + + const result = (await sdk.trigger("mem::facet-get", { + targetId: "act_1", + })) as { success: boolean; dimensions: Array<{ dimension: string; values: string[] }> }; + + expect(result.success).toBe(true); + expect(result.dimensions.length).toBe(2); + + const priorityDim = result.dimensions.find((d) => d.dimension === "priority"); + expect(priorityDim).toBeDefined(); + expect(priorityDim!.values).toEqual(["high", "urgent"]); + + const statusDim = result.dimensions.find((d) => d.dimension === "status"); + expect(statusDim).toBeDefined(); + expect(statusDim!.values).toEqual(["active"]); + }); + + it("returns empty dimensions for target with no facets", async () => { + const result = (await sdk.trigger("mem::facet-get", { + targetId: "nonexistent", + })) as { success: boolean; dimensions: Array<{ dimension: string; values: string[] }> }; + + expect(result.success).toBe(true); + expect(result.dimensions).toEqual([]); + }); + }); + + describe("mem::facet-stats", () => { + beforeEach(async () => { + await sdk.trigger("mem::facet-tag", { + targetId: "act_1", + targetType: "action", + dimension: "priority", + value: "high", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_2", + targetType: "action", + dimension: "priority", + value: "high", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_3", + targetType: "action", + dimension: "priority", + value: "low", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "mem_1", + targetType: "memory", + dimension: "category", + value: "bugfix", + }); + }); + + it("returns dimensions with value counts", async () => { + const result = (await sdk.trigger("mem::facet-stats", {})) as { + success: boolean; + dimensions: Array<{ + dimension: string; + values: Array<{ value: string; count: number }>; + }>; + totalFacets: number; + }; + + expect(result.success).toBe(true); + expect(result.totalFacets).toBe(4); + expect(result.dimensions.length).toBe(2); + + const priorityDim = result.dimensions.find((d) => d.dimension === "priority"); + expect(priorityDim).toBeDefined(); + const highVal = priorityDim!.values.find((v) => v.value === "high"); + expect(highVal!.count).toBe(2); + const lowVal = priorityDim!.values.find((v) => v.value === "low"); + expect(lowVal!.count).toBe(1); + }); + + it("filters by targetType", async () => { + const result = (await sdk.trigger("mem::facet-stats", { + targetType: "memory", + })) as { + success: boolean; + dimensions: Array<{ + dimension: string; + values: Array<{ value: string; count: number }>; + }>; + totalFacets: number; + }; + + expect(result.success).toBe(true); + expect(result.totalFacets).toBe(1); + expect(result.dimensions.length).toBe(1); + expect(result.dimensions[0].dimension).toBe("category"); + expect(result.dimensions[0].values[0].value).toBe("bugfix"); + expect(result.dimensions[0].values[0].count).toBe(1); + }); + }); + + describe("mem::facet-dimensions", () => { + it("returns unique dimension names with counts", async () => { + await sdk.trigger("mem::facet-tag", { + targetId: "act_1", + targetType: "action", + dimension: "priority", + value: "high", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_2", + targetType: "action", + dimension: "priority", + value: "low", + }); + await sdk.trigger("mem::facet-tag", { + targetId: "act_1", + targetType: "action", + dimension: "status", + value: "active", + }); + + const result = (await sdk.trigger("mem::facet-dimensions", {})) as { + success: boolean; + dimensions: Array<{ dimension: string; count: number }>; + }; + + expect(result.success).toBe(true); + expect(result.dimensions.length).toBe(2); + + const priorityDim = result.dimensions.find((d) => d.dimension === "priority"); + expect(priorityDim).toBeDefined(); + expect(priorityDim!.count).toBe(2); + + const statusDim = result.dimensions.find((d) => d.dimension === "status"); + expect(statusDim).toBeDefined(); + expect(statusDim!.count).toBe(1); + }); + }); +}); diff --git a/test/fallback-chain.test.ts b/test/fallback-chain.test.ts new file mode 100644 index 0000000..5113b69 --- /dev/null +++ b/test/fallback-chain.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { FallbackChainProvider } from "../src/providers/fallback-chain.js"; +import type { MemoryProvider } from "../src/types.js"; + +function makeProvider( + name: string, + impl?: Partial, +): MemoryProvider { + return { + name, + compress: impl?.compress ?? (async () => `compressed by ${name}`), + summarize: impl?.summarize ?? (async () => `summarized by ${name}`), + }; +} + +describe("FallbackChainProvider", () => { + it("returns result from first provider when it succeeds", async () => { + const chain = new FallbackChainProvider([ + makeProvider("primary"), + makeProvider("secondary"), + ]); + const result = await chain.compress("sys", "user"); + expect(result).toBe("compressed by primary"); + }); + + it("falls back to second provider when first fails", async () => { + const failing: MemoryProvider = { + name: "failing", + compress: async () => { + throw new Error("primary down"); + }, + summarize: async () => { + throw new Error("primary down"); + }, + }; + const chain = new FallbackChainProvider([ + failing, + makeProvider("backup"), + ]); + const result = await chain.compress("sys", "user"); + expect(result).toBe("compressed by backup"); + }); + + it("throws the last error when all providers fail", async () => { + const failing1: MemoryProvider = { + name: "fail1", + compress: async () => { + throw new Error("fail1 error"); + }, + summarize: async () => { + throw new Error("fail1 error"); + }, + }; + const failing2: MemoryProvider = { + name: "fail2", + compress: async () => { + throw new Error("fail2 error"); + }, + summarize: async () => { + throw new Error("fail2 error"); + }, + }; + const chain = new FallbackChainProvider([failing1, failing2]); + await expect(chain.compress("sys", "user")).rejects.toThrow("fail2 error"); + }); + + it("formats the name correctly", () => { + const chain = new FallbackChainProvider([ + makeProvider("anthropic"), + makeProvider("gemini"), + makeProvider("openrouter"), + ]); + expect(chain.name).toBe("fallback(anthropic -> gemini -> openrouter)"); + }); + + it("summarize also uses fallback chain", async () => { + const failing: MemoryProvider = { + name: "failing", + compress: async () => { + throw new Error("down"); + }, + summarize: async () => { + throw new Error("down"); + }, + }; + const chain = new FallbackChainProvider([ + failing, + makeProvider("backup"), + ]); + const result = await chain.summarize("sys", "user"); + expect(result).toBe("summarized by backup"); + }); +}); diff --git a/test/fallback-model-resolution.test.ts b/test/fallback-model-resolution.test.ts new file mode 100644 index 0000000..91a8211 --- /dev/null +++ b/test/fallback-model-resolution.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// #778: fallback providers used to inherit the primary provider's +// model name and 404 on every call. Each fallback must resolve its +// own env-driven default model. + +const captured: Array<{ provider: string; model: string }> = []; + +vi.mock("../src/providers/openai.js", () => ({ + OpenAIProvider: class { + name = "openai"; + constructor(_key: string, model: string) { + captured.push({ provider: "openai", model }); + } + async compress() { + return ""; + } + async summarize() { + return ""; + } + }, +})); + +vi.mock("../src/providers/openrouter.js", () => ({ + OpenRouterProvider: class { + name = "openrouter"; + constructor(_key: string, model: string, _max: number, url?: string) { + captured.push({ + provider: url?.includes("googleapis") ? "gemini" : "openrouter", + model, + }); + } + async compress() { + return ""; + } + async summarize() { + return ""; + } + }, +})); + +vi.mock("../src/providers/anthropic.js", () => ({ + AnthropicProvider: class { + name = "anthropic"; + constructor(_key: string, model: string) { + captured.push({ provider: "anthropic", model }); + } + async compress() { + return ""; + } + async summarize() { + return ""; + } + }, +})); + +vi.mock("../src/providers/minimax.js", () => ({ + MinimaxProvider: class { + name = "minimax"; + constructor(_key: string, model: string) { + captured.push({ provider: "minimax", model }); + } + async compress() { + return ""; + } + async summarize() { + return ""; + } + }, +})); + +import { createFallbackProvider } from "../src/providers/index.js"; +import type { ProviderConfig, FallbackConfig } from "../src/types.js"; + +describe("Fallback provider model resolution (#778)", () => { + const savedEnv: Record = {}; + const envKeys = [ + "OPENAI_API_KEY", + "OPENAI_MODEL", + "GEMINI_API_KEY", + "GEMINI_MODEL", + "GOOGLE_API_KEY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_MODEL", + "OPENROUTER_API_KEY", + "OPENROUTER_MODEL", + "MINIMAX_API_KEY", + "MINIMAX_MODEL", + ]; + + beforeEach(() => { + captured.length = 0; + for (const k of envKeys) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of envKeys) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + }); + + it("primary OpenAI + fallback Gemini: Gemini is built with GEMINI_MODEL, NOT the primary's model", () => { + process.env.OPENAI_API_KEY = "sk-openai"; + process.env.GEMINI_API_KEY = "gemini-key"; + process.env.GEMINI_MODEL = "gemini-2.5-flash"; + + const primary: ProviderConfig = { + provider: "openai", + model: "gpt-4o-mini", + maxTokens: 4096, + }; + const fallback: FallbackConfig = { providers: ["gemini"] }; + + createFallbackProvider(primary, fallback); + + const openaiCall = captured.find((c) => c.provider === "openai"); + const geminiCall = captured.find((c) => c.provider === "gemini"); + expect(openaiCall?.model).toBe("gpt-4o-mini"); + expect(geminiCall?.model).toBe("gemini-2.5-flash"); + expect(geminiCall?.model).not.toBe("gpt-4o-mini"); + }); + + it("Gemini fallback uses the documented default when GEMINI_MODEL is unset", () => { + process.env.OPENAI_API_KEY = "sk-openai"; + process.env.GEMINI_API_KEY = "gemini-key"; + + createFallbackProvider( + { provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 }, + { providers: ["gemini"] }, + ); + + const geminiCall = captured.find((c) => c.provider === "gemini"); + expect(geminiCall?.model).toBe("gemini-2.5-flash"); + }); + + it("primary Anthropic + fallback OpenAI + Minimax: each fallback uses its own default", () => { + process.env.ANTHROPIC_API_KEY = "anth-key"; + process.env.OPENAI_API_KEY = "openai-key"; + process.env.OPENAI_MODEL = "gpt-5"; + process.env.MINIMAX_API_KEY = "mini-key"; + + createFallbackProvider( + { + provider: "anthropic", + model: "claude-sonnet-4-20250514", + maxTokens: 4096, + }, + { providers: ["openai", "minimax"] }, + ); + + const openai = captured.find((c) => c.provider === "openai"); + const minimax = captured.find((c) => c.provider === "minimax"); + expect(openai?.model).toBe("gpt-5"); + expect(minimax?.model).toBe("MiniMax-M2.7"); + // Neither inherits the Anthropic model name. + expect(openai?.model).not.toBe("claude-sonnet-4-20250514"); + expect(minimax?.model).not.toBe("claude-sonnet-4-20250514"); + }); + + it("env override on the fallback provider's MODEL var wins over the default", () => { + process.env.OPENAI_API_KEY = "sk"; + process.env.GEMINI_API_KEY = "gk"; + process.env.GEMINI_MODEL = "gemini-2.5-pro"; + + createFallbackProvider( + { provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 }, + { providers: ["gemini"] }, + ); + + expect(captured.find((c) => c.provider === "gemini")?.model).toBe( + "gemini-2.5-pro", + ); + }); + + it("fallback that matches the primary provider is skipped (no duplicate)", () => { + process.env.OPENAI_API_KEY = "sk"; + + createFallbackProvider( + { provider: "openai", model: "gpt-4o-mini", maxTokens: 4096 }, + { providers: ["openai", "gemini"] }, + ); + + const openaiCalls = captured.filter((c) => c.provider === "openai"); + expect(openaiCalls.length).toBe(1); + }); +}); diff --git a/test/fetch-timeout.test.ts b/test/fetch-timeout.test.ts new file mode 100644 index 0000000..5b2cd7c --- /dev/null +++ b/test/fetch-timeout.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { fetchWithTimeout } from "../src/providers/_fetch.js"; +import { MinimaxProvider } from "../src/providers/minimax.js"; +import { OpenRouterProvider } from "../src/providers/openrouter.js"; +import { OpenAIProvider } from "../src/providers/openai.js"; +import { GeminiEmbeddingProvider } from "../src/providers/embedding/gemini.js"; +import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; +import { CohereEmbeddingProvider } from "../src/providers/embedding/cohere.js"; +import { VoyageEmbeddingProvider } from "../src/providers/embedding/voyage.js"; +import { OpenRouterEmbeddingProvider } from "../src/providers/embedding/openrouter.js"; + +// A fetch mock that never resolves — simulates a hung upstream. +function hangingFetch(_url: string, _init?: RequestInit): Promise { + // honour AbortSignal so the timeout actually cancels us + const init = _init ?? {}; + return new Promise((_resolve, reject) => { + if (init.signal) { + if (init.signal.aborted) { + reject(new DOMException("AbortError", "AbortError")); + return; + } + init.signal.addEventListener("abort", () => { + reject(new DOMException("AbortError", "AbortError")); + }); + } + }); +} + +// ───────────────────────────────────────────────────────────── +// fetchWithTimeout unit tests +// ───────────────────────────────────────────────────────────── +describe("fetchWithTimeout", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("resolves normally when fetch completes within the timeout", async () => { + vi.restoreAllMocks(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }), + ); + const res = await fetchWithTimeout("https://example.com", {}, 1000); + expect(res.status).toBe(200); + }); + + it("aborts with an AbortError when fetch hangs beyond the configured timeout", async () => { + await expect( + fetchWithTimeout("https://example.com", {}, 50), + ).rejects.toThrow(); + }); + + it("reads AGENTMEMORY_LLM_TIMEOUT_MS as the default timeout when no explicit ms is given", async () => { + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + // no explicit third arg — must pick up the env var + await expect( + fetchWithTimeout("https://example.com", {}), + ).rejects.toThrow(); + }); + + it("falls back to 60 000 ms when AGENTMEMORY_LLM_TIMEOUT_MS is not set (type check only)", () => { + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + vi.restoreAllMocks(); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { status: 204 }), + ); + const p = fetchWithTimeout("https://example.com", {}); + expect(p).toBeInstanceOf(Promise); + return p; + }); +}); + +// ───────────────────────────────────────────────────────────── +// Provider hang regression tests +// Each provider must call fetchWithTimeout, which honours the +// AbortSignal when the explicit timeoutMs is tiny (50 ms). +// ───────────────────────────────────────────────────────────── + +describe("Provider hang regression — MinimaxProvider", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("compress() aborts after timeout when upstream hangs", async () => { + const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800); + await expect(provider.compress("system", "user")).rejects.toThrow(); + }); +}); + +describe("Provider hang regression — OpenRouterProvider (covers Gemini LLM path)", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("compress() aborts after timeout when upstream hangs", async () => { + const provider = new OpenRouterProvider( + "test-key", + "gemini-2.5-flash", + 1024, + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + ); + await expect(provider.compress("system", "user")).rejects.toThrow(); + }); +}); + +describe("Provider hang regression — GeminiEmbeddingProvider", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("embedBatch() aborts after timeout when upstream hangs", async () => { + const provider = new GeminiEmbeddingProvider("test-key"); + await expect(provider.embedBatch(["hello"])).rejects.toThrow(); + }); +}); + +describe("Provider hang regression — OpenAIEmbeddingProvider", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("embedBatch() aborts after timeout when upstream hangs", async () => { + const provider = new OpenAIEmbeddingProvider("test-key"); + await expect(provider.embedBatch(["hello"])).rejects.toThrow(); + }); +}); + +describe("Provider hang regression — CohereEmbeddingProvider", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("embedBatch() aborts after timeout when upstream hangs", async () => { + const provider = new CohereEmbeddingProvider("test-key"); + await expect(provider.embedBatch(["hello"])).rejects.toThrow(); + }); +}); + +describe("Provider hang regression — VoyageEmbeddingProvider", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("embedBatch() aborts after timeout when upstream hangs", async () => { + const provider = new VoyageEmbeddingProvider("test-key"); + await expect(provider.embedBatch(["hello"])).rejects.toThrow(); + }); +}); + +describe("Provider hang regression — OpenRouterEmbeddingProvider", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "50"; + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("embedBatch() aborts after timeout when upstream hangs", async () => { + const provider = new OpenRouterEmbeddingProvider("test-key"); + await expect(provider.embedBatch(["hello"])).rejects.toThrow(); + }); +}); + +// ───────────────────────────────────────────────────────────── +// #446 — OpenAI LLM provider env-var precedence +// +// v0.9.17 shipped OPENAI_TIMEOUT_MS (OpenAI-scoped). PR #379 then +// shipped AGENTMEMORY_LLM_TIMEOUT_MS (shared). The provider now +// honours both: OPENAI_TIMEOUT_MS wins for back-compat, with +// AGENTMEMORY_LLM_TIMEOUT_MS as the global fall-back. +// ───────────────────────────────────────────────────────────── +describe("OpenAIProvider timeout env precedence (#446)", () => { + beforeEach(() => { + delete process.env["OPENAI_TIMEOUT_MS"]; + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + vi.spyOn(globalThis, "fetch").mockImplementation(hangingFetch as typeof fetch); + }); + afterEach(() => { + vi.restoreAllMocks(); + delete process.env["OPENAI_TIMEOUT_MS"]; + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + + it("OPENAI_TIMEOUT_MS alone aborts the OpenAI LLM call", async () => { + process.env["OPENAI_TIMEOUT_MS"] = "30"; + const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024); + await expect(provider.compress("system", "user")).rejects.toThrow( + /timed out after 30ms/, + ); + }); + + it("AGENTMEMORY_LLM_TIMEOUT_MS alone aborts the OpenAI LLM call", async () => { + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "30"; + const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024); + await expect(provider.compress("system", "user")).rejects.toThrow( + /timed out after 30ms/, + ); + }); + + it("OPENAI_TIMEOUT_MS wins when both are set (back-compat)", async () => { + process.env["OPENAI_TIMEOUT_MS"] = "30"; + // Set the global to a much larger value — if precedence is wrong, + // we'd time out at 5000ms and the test would hang past the 5s + // vitest default. We assert the message ms to lock the precedence. + process.env["AGENTMEMORY_LLM_TIMEOUT_MS"] = "5000"; + const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024); + await expect(provider.compress("system", "user")).rejects.toThrow( + /timed out after 30ms/, + ); + }); + + it("falls back to the 60 000 ms default when neither is set", () => { + // We don't actually wait 60s — the provider stores timeoutMs at + // construction. Construct, then assert the bound via the error + // message after the hang aborts at a tiny pre-set value. + const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024); + // Access the resolved timeout via the constructed field name. The + // class keeps `timeoutMs` private; reaching in via the index + // access keeps the test on the public observed behaviour: the ms + // value reported in the timeout error message must be 60000. + const ms = (provider as unknown as { timeoutMs: number }).timeoutMs; + expect(ms).toBe(60_000); + }); + + it("rejects malformed env values like '30ms' or '1_000' (CodeRabbit catch)", () => { + // parseInt would have silently returned 30 / 1 for these typos — + // strict parse now rejects them and the provider falls back to + // the 60 000 ms default so a malformed env doesn't masquerade as + // an aggressive bound. + // Whitespace-only padding (" 30 ") is legitimate env-var handling — we + // trim before validating. The cases below are real typos parseInt would + // silently swallow. + for (const bad of ["30ms", "1_000", "60s", "30abc", "-30", "0"]) { + process.env["OPENAI_TIMEOUT_MS"] = bad; + const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024); + const ms = (provider as unknown as { timeoutMs: number }).timeoutMs; + expect(ms).toBe(60_000); + delete process.env["OPENAI_TIMEOUT_MS"]; + } + }); +}); + +// ───────────────────────────────────────────────────────────── +// #627 — OpenAI provider must read message.reasoning_content +// DeepSeek V4 / Qwen3 / GLM / Kimi return reasoning_content (with +// underscore); only checking `reasoning` left thinking-model output +// dropped on the floor and tripped the compress circuit breaker. +// ───────────────────────────────────────────────────────────── +describe("OpenAIProvider thinking-model fallback (#627)", () => { + beforeEach(() => { + delete process.env["OPENAI_TIMEOUT_MS"]; + delete process.env["AGENTMEMORY_LLM_TIMEOUT_MS"]; + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + function mockOpenAIResponse(body: object): void { + vi.spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch, + ); + } + + it("returns reasoning_content when content is empty (DeepSeek V4 / Qwen3 shape)", async () => { + mockOpenAIResponse({ + choices: [ + { + message: { + content: "", + reasoning_content: "thinking-mode output", + }, + }, + ], + }); + const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024); + const out = await provider.compress("system", "user"); + expect(out).toBe("thinking-mode output"); + }); + + it("still returns reasoning (no underscore) for older o-series shape", async () => { + mockOpenAIResponse({ + choices: [{ message: { content: "", reasoning: "older shape" } }], + }); + const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024); + const out = await provider.compress("system", "user"); + expect(out).toBe("older shape"); + }); + + it("content wins over both reasoning fields when present", async () => { + mockOpenAIResponse({ + choices: [ + { + message: { + content: "real content", + reasoning: "ignore", + reasoning_content: "also ignore", + }, + }, + ], + }); + const provider = new OpenAIProvider("test-key", "gpt-4o-mini", 1024); + const out = await provider.compress("system", "user"); + expect(out).toBe("real content"); + }); +}); + diff --git a/test/fixtures/jsonl/basic.jsonl b/test/fixtures/jsonl/basic.jsonl new file mode 100644 index 0000000..18f9a2a --- /dev/null +++ b/test/fixtures/jsonl/basic.jsonl @@ -0,0 +1,2 @@ +{"type":"user","uuid":"u1","sessionId":"sess-basic","timestamp":"2026-04-17T10:00:00.000Z","cwd":"/Users/alice/project","message":{"role":"user","content":[{"type":"text","text":"Fix the login bug"}]}} +{"type":"assistant","uuid":"a1","sessionId":"sess-basic","timestamp":"2026-04-17T10:00:05.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Looking into it now."}]}} diff --git a/test/fixtures/jsonl/errors.jsonl b/test/fixtures/jsonl/errors.jsonl new file mode 100644 index 0000000..63c98a8 --- /dev/null +++ b/test/fixtures/jsonl/errors.jsonl @@ -0,0 +1,4 @@ +{"type":"user","uuid":"u1","sessionId":"sess-err","timestamp":"2026-04-17T12:00:00.000Z","cwd":"/tmp/x","message":{"role":"user","content":[{"type":"text","text":"Run tests"}]}} +not-valid-json-line +{"type":"assistant","uuid":"a1","sessionId":"sess-err","timestamp":"2026-04-17T12:00:01.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"npm test"}}]}} +{"type":"user","uuid":"u2","sessionId":"sess-err","timestamp":"2026-04-17T12:00:02.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"exit 1","is_error":true}]}} diff --git a/test/fixtures/jsonl/tool-use.jsonl b/test/fixtures/jsonl/tool-use.jsonl new file mode 100644 index 0000000..f96f5be --- /dev/null +++ b/test/fixtures/jsonl/tool-use.jsonl @@ -0,0 +1,4 @@ +{"type":"user","uuid":"u1","sessionId":"sess-tool","timestamp":"2026-04-17T11:00:00.000Z","cwd":"/Users/bob/repo","message":{"role":"user","content":[{"type":"text","text":"List the files"}]}} +{"type":"assistant","uuid":"a1","sessionId":"sess-tool","timestamp":"2026-04-17T11:00:02.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}} +{"type":"user","uuid":"u2","sessionId":"sess-tool","timestamp":"2026-04-17T11:00:03.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"README.md\nsrc\n"}]}} +{"type":"assistant","uuid":"a2","sessionId":"sess-tool","timestamp":"2026-04-17T11:00:04.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Two entries."}]}} diff --git a/test/frontier.test.ts b/test/frontier.test.ts new file mode 100644 index 0000000..2be7f5c --- /dev/null +++ b/test/frontier.test.ts @@ -0,0 +1,492 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerFrontierFunction } from "../src/functions/frontier.js"; +import { registerActionsFunction } from "../src/functions/actions.js"; +import type { Action, ActionEdge, Checkpoint, Lease } from "../src/types.js"; +import type { FrontierItem } from "../src/functions/frontier.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeAction(overrides: Partial): Action { + const now = new Date().toISOString(); + return { + id: overrides.id || `act_${Math.random().toString(36).slice(2, 10)}`, + title: overrides.title || "Test action", + description: overrides.description || "", + status: overrides.status || "pending", + priority: overrides.priority || 5, + createdAt: overrides.createdAt || now, + updatedAt: overrides.updatedAt || now, + createdBy: overrides.createdBy || "agent-1", + assignedTo: overrides.assignedTo, + project: overrides.project, + tags: overrides.tags || [], + sourceObservationIds: overrides.sourceObservationIds || [], + sourceMemoryIds: overrides.sourceMemoryIds || [], + result: overrides.result, + parentId: overrides.parentId, + metadata: overrides.metadata, + }; +} + +describe("Frontier Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerActionsFunction(sdk as never, kv as never); + registerFrontierFunction(sdk as never, kv as never); + }); + + describe("mem::frontier", () => { + it("returns empty frontier when no actions exist", async () => { + const result = (await sdk.trigger("mem::frontier", {})) as { + success: boolean; + frontier: FrontierItem[]; + totalActions: number; + totalUnblocked: number; + }; + + expect(result.success).toBe(true); + expect(result.frontier).toEqual([]); + expect(result.totalActions).toBe(0); + expect(result.totalUnblocked).toBe(0); + }); + + it("returns pending actions sorted by score", async () => { + const lowPriority = makeAction({ + id: "act_low", + title: "Low priority", + priority: 2, + }); + const highPriority = makeAction({ + id: "act_high", + title: "High priority", + priority: 9, + }); + + await kv.set("mem:actions", lowPriority.id, lowPriority); + await kv.set("mem:actions", highPriority.id, highPriority); + + const result = (await sdk.trigger("mem::frontier", {})) as { + success: boolean; + frontier: FrontierItem[]; + }; + + expect(result.success).toBe(true); + expect(result.frontier.length).toBe(2); + expect(result.frontier[0].action.id).toBe("act_high"); + expect(result.frontier[1].action.id).toBe("act_low"); + expect(result.frontier[0].score).toBeGreaterThan( + result.frontier[1].score, + ); + }); + + it("excludes done and cancelled actions", async () => { + const pending = makeAction({ + id: "act_pending", + title: "Pending", + status: "pending", + }); + const done = makeAction({ + id: "act_done", + title: "Done", + status: "done", + }); + const cancelled = makeAction({ + id: "act_cancelled", + title: "Cancelled", + status: "cancelled", + }); + + await kv.set("mem:actions", pending.id, pending); + await kv.set("mem:actions", done.id, done); + await kv.set("mem:actions", cancelled.id, cancelled); + + const result = (await sdk.trigger("mem::frontier", {})) as { + success: boolean; + frontier: FrontierItem[]; + totalActions: number; + }; + + expect(result.success).toBe(true); + expect(result.frontier.length).toBe(1); + expect(result.frontier[0].action.id).toBe("act_pending"); + expect(result.totalActions).toBe(3); + }); + + it("excludes blocked actions with unsatisfied requires edge", async () => { + const dependency = makeAction({ + id: "act_dep", + title: "Dependency", + status: "pending", + }); + const blocked = makeAction({ + id: "act_blocked", + title: "Blocked", + status: "blocked", + }); + + await kv.set("mem:actions", dependency.id, dependency); + await kv.set("mem:actions", blocked.id, blocked); + + const edge: ActionEdge = { + id: "ae_1", + type: "requires", + sourceActionId: blocked.id, + targetActionId: dependency.id, + createdAt: new Date().toISOString(), + }; + await kv.set("mem:action-edges", edge.id, edge); + + const result = (await sdk.trigger("mem::frontier", {})) as { + success: boolean; + frontier: FrontierItem[]; + }; + + expect(result.success).toBe(true); + const ids = result.frontier.map((f) => f.action.id); + expect(ids).toContain("act_dep"); + expect(ids).not.toContain("act_blocked"); + }); + + it("respects project filter", async () => { + const alphaAction = makeAction({ + id: "act_alpha", + title: "Alpha task", + project: "alpha", + }); + const betaAction = makeAction({ + id: "act_beta", + title: "Beta task", + project: "beta", + }); + + await kv.set("mem:actions", alphaAction.id, alphaAction); + await kv.set("mem:actions", betaAction.id, betaAction); + + const result = (await sdk.trigger("mem::frontier", { + project: "alpha", + })) as { success: boolean; frontier: FrontierItem[] }; + + expect(result.success).toBe(true); + expect(result.frontier.length).toBe(1); + expect(result.frontier[0].action.project).toBe("alpha"); + }); + + it("higher priority scores higher", async () => { + const low = makeAction({ + id: "act_low", + title: "Low", + priority: 1, + createdAt: new Date().toISOString(), + }); + const high = makeAction({ + id: "act_high", + title: "High", + priority: 10, + createdAt: new Date().toISOString(), + }); + + await kv.set("mem:actions", low.id, low); + await kv.set("mem:actions", high.id, high); + + const result = (await sdk.trigger("mem::frontier", {})) as { + success: boolean; + frontier: FrontierItem[]; + }; + + expect(result.frontier[0].action.id).toBe("act_high"); + expect(result.frontier[0].score).toBeGreaterThan( + result.frontier[1].score, + ); + }); + + it("excludes actions gated by pending checkpoint", async () => { + const gatedAction = makeAction({ + id: "act_gated", + title: "Gated action", + status: "pending", + }); + + const checkpoint: Checkpoint = { + id: "ckpt_1", + name: "CI check", + description: "Waiting for CI", + status: "pending", + type: "ci", + createdAt: new Date().toISOString(), + linkedActionIds: ["act_gated"], + }; + + await kv.set("mem:actions", gatedAction.id, gatedAction); + await kv.set("mem:checkpoints", checkpoint.id, checkpoint); + + const gateEdge: ActionEdge = { + id: "ae_gate", + type: "gated_by", + sourceActionId: gatedAction.id, + targetActionId: checkpoint.id, + createdAt: new Date().toISOString(), + }; + await kv.set("mem:action-edges", gateEdge.id, gateEdge); + + const result = (await sdk.trigger("mem::frontier", {})) as { + success: boolean; + frontier: FrontierItem[]; + }; + + expect(result.frontier.length).toBe(0); + }); + + it("excludes actions conflicting with active actions", async () => { + const activeAction = makeAction({ + id: "act_active", + title: "Active task", + status: "active", + }); + const conflictAction = makeAction({ + id: "act_conflict", + title: "Conflicting task", + status: "pending", + }); + + await kv.set("mem:actions", activeAction.id, activeAction); + await kv.set("mem:actions", conflictAction.id, conflictAction); + + const conflictEdge: ActionEdge = { + id: "ae_conflict", + type: "conflicts_with", + sourceActionId: conflictAction.id, + targetActionId: activeAction.id, + createdAt: new Date().toISOString(), + }; + await kv.set("mem:action-edges", conflictEdge.id, conflictEdge); + + const result = (await sdk.trigger("mem::frontier", {})) as { + success: boolean; + frontier: FrontierItem[]; + }; + + const ids = result.frontier.map((f) => f.action.id); + expect(ids).toContain("act_active"); + expect(ids).not.toContain("act_conflict"); + }); + + it("active actions get score bonus", async () => { + const pendingAction = makeAction({ + id: "act_pending", + title: "Pending", + status: "pending", + priority: 5, + createdAt: new Date().toISOString(), + }); + const activeAction = makeAction({ + id: "act_active", + title: "Active", + status: "active", + priority: 5, + createdAt: new Date().toISOString(), + }); + + await kv.set("mem:actions", pendingAction.id, pendingAction); + await kv.set("mem:actions", activeAction.id, activeAction); + + const result = (await sdk.trigger("mem::frontier", {})) as { + success: boolean; + frontier: FrontierItem[]; + }; + + const activeItem = result.frontier.find( + (f) => f.action.id === "act_active", + )!; + const pendingItem = result.frontier.find( + (f) => f.action.id === "act_pending", + )!; + + expect(activeItem.score).toBeGreaterThan(pendingItem.score); + }); + }); + + describe("mem::next", () => { + it("returns top suggestion when actions exist", async () => { + const action = makeAction({ + id: "act_1", + title: "Top task", + priority: 8, + tags: ["urgent"], + }); + await kv.set("mem:actions", action.id, action); + + const result = (await sdk.trigger("mem::next", {})) as { + success: boolean; + suggestion: { + actionId: string; + title: string; + description: string; + priority: number; + score: number; + tags: string[]; + } | null; + message: string; + totalActions: number; + }; + + expect(result.success).toBe(true); + expect(result.suggestion).not.toBeNull(); + expect(result.suggestion!.actionId).toBe("act_1"); + expect(result.suggestion!.title).toBe("Top task"); + expect(result.suggestion!.priority).toBe(8); + expect(result.suggestion!.tags).toEqual(["urgent"]); + expect(result.message).toContain("Top task"); + expect(result.totalActions).toBe(1); + }); + + it("returns null suggestion when no actions exist", async () => { + const result = (await sdk.trigger("mem::next", {})) as { + success: boolean; + suggestion: null; + message: string; + totalActions: number; + }; + + expect(result.success).toBe(true); + expect(result.suggestion).toBeNull(); + expect(result.message).toContain("No actionable work"); + expect(result.totalActions).toBe(0); + }); + + it("returns null when all actions are done", async () => { + const doneAction = makeAction({ + id: "act_done", + title: "Completed", + status: "done", + }); + await kv.set("mem:actions", doneAction.id, doneAction); + + const result = (await sdk.trigger("mem::next", {})) as { + success: boolean; + suggestion: null; + message: string; + totalActions: number; + }; + + expect(result.success).toBe(true); + expect(result.suggestion).toBeNull(); + expect(result.totalActions).toBe(1); + }); + + it("propagates failure when frontier fails", async () => { + const originalFunctions = new Map(); + + const failSdk = { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + originalFunctions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + if (id === "mem::frontier") { + return { success: false, error: "internal failure" }; + } + const fn = originalFunctions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; + + const failKv = mockKV(); + registerFrontierFunction(failSdk as never, failKv as never); + + const nextFn = originalFunctions.get("mem::next")!; + const result = (await nextFn({})) as { + success: boolean; + suggestion: null; + message: string; + totalActions: number; + }; + + expect(result.success).toBe(false); + expect(result.suggestion).toBeNull(); + expect(result.message).toContain("Failed to compute frontier"); + expect(result.totalActions).toBe(0); + }); + + it("respects project filter", async () => { + const alphaAction = makeAction({ + id: "act_alpha", + title: "Alpha task", + project: "alpha", + priority: 5, + }); + const betaAction = makeAction({ + id: "act_beta", + title: "Beta task", + project: "beta", + priority: 10, + }); + + await kv.set("mem:actions", alphaAction.id, alphaAction); + await kv.set("mem:actions", betaAction.id, betaAction); + + const result = (await sdk.trigger("mem::next", { + project: "alpha", + })) as { + success: boolean; + suggestion: { actionId: string; title: string } | null; + }; + + expect(result.success).toBe(true); + expect(result.suggestion).not.toBeNull(); + expect(result.suggestion!.actionId).toBe("act_alpha"); + }); + }); +}); diff --git a/test/fs-watcher.test.ts b/test/fs-watcher.test.ts new file mode 100644 index 0000000..48c1b09 --- /dev/null +++ b/test/fs-watcher.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { FilesystemWatcher, configFromEnv } from "../integrations/filesystem-watcher/watcher.mjs"; + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), "fs-watch-")); +} + +function wait(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +describe("FilesystemWatcher", { retry: 2 }, () => { + let root: string; + const originalFetch = globalThis.fetch; + let captured: Array<{ url: string; body: unknown; headers: Record }>; + + beforeEach(() => { + root = tempDir(); + captured = []; + (globalThis as { fetch: typeof fetch }).fetch = (async ( + url: string | URL, + init?: RequestInit, + ) => { + captured.push({ + url: url.toString(), + body: init?.body ? JSON.parse(init.body as string) : null, + headers: (init?.headers || {}) as Record, + }); + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + try { + rmSync(root, { recursive: true, force: true }); + } catch {} + }); + + it("emits a post_tool_use observation with HookPayload shape on write", async () => { + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + w.start(); + try { + writeFileSync(join(root, "notes.md"), "hello world\n"); + await wait(1500); + expect(captured.length).toBeGreaterThanOrEqual(1); + const obs = captured[captured.length - 1]; + expect(obs.url).toBe("http://localhost:3111/agentmemory/observe"); + const body = obs.body as { + hookType: string; + sessionId: string; + project: string; + cwd: string; + timestamp: string; + data: { changeKind: string; files: string[]; content: string; source: string }; + }; + expect(body.hookType).toBe("post_tool_use"); + expect(typeof body.sessionId).toBe("string"); + expect(body.sessionId.length).toBeGreaterThan(0); + expect(typeof body.project).toBe("string"); + expect(body.project.length).toBeGreaterThan(0); + expect(body.cwd).toBe(root); + expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(body.data.source).toBe("filesystem-watcher"); + expect(body.data.changeKind).toBe("file_change"); + expect(body.data.files).toContain("notes.md"); + expect(body.data.content).toContain("hello world"); + } finally { + w.stop(); + } + }); + + it("emits changeKind=file_delete when a watched file is removed", async () => { + writeFileSync(join(root, "old.md"), "bye\n"); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + w.start(); + try { + unlinkSync(join(root, "old.md")); + await wait(1500); + const deletes = captured.filter( + (c) => (c.body as { data: { changeKind: string } }).data?.changeKind === "file_delete", + ); + expect(deletes.length).toBeGreaterThanOrEqual(1); + } finally { + w.stop(); + } + }); + + it("throws if no watched roots could be attached", () => { + const w = new FilesystemWatcher({ + roots: ["/definitely/does/not/exist/xyz123"], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + expect(() => w.start()).toThrow(/could not watch any of the configured roots/); + }); + + it("ignores paths that match the default ignore set", async () => { + mkdirSync(join(root, "node_modules"), { recursive: true }); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + w.start(); + try { + writeFileSync(join(root, "node_modules", "ignored.js"), "x"); + await wait(1500); + const matches = captured.filter((c) => + (c.body as { data: { files: string[] } }).data?.files?.some((f) => f.includes("ignored.js")), + ); + expect(matches).toHaveLength(0); + } finally { + w.stop(); + } + }); + + it("attaches Bearer auth when a secret is configured", async () => { + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + secret: "shhh", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + w.start(); + try { + writeFileSync(join(root, "secret.md"), "bearer test\n"); + await wait(1500); + expect(captured.length).toBeGreaterThanOrEqual(1); + const headers = captured[captured.length - 1].headers as Record; + expect(headers.authorization).toBe("Bearer shhh"); + } finally { + w.stop(); + } + }); + + it("redacts sensitive dotenv preview values before sending observations", async () => { + writeFileSync( + join(root, ".env"), + [ + "OPENAI_API_KEY=sk-test-secret-value", + "PUBLIC_FLAG=enabled", + "AUTHORIZATION=Bearer live-token-value", + ].join("\n"), + ); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, ".env"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain("OPENAI_API_KEY=[REDACTED]"); + expect(content).toContain("PUBLIC_FLAG=enabled"); + expect(content).toContain("AUTHORIZATION=[REDACTED]"); + expect(content).not.toContain("sk-test-secret-value"); + expect(content).not.toContain("live-token-value"); + }); + + it("redacts quoted JSON-style sensitive keys before sending observations", async () => { + writeFileSync( + join(root, "settings.json"), + [ + '{', + ' "api_key": "json-preview-secret",', + ' "public_flag": "enabled"', + '}', + ].join("\n"), + ); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, "settings.json"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain('"api_key": [REDACTED]'); + expect(content).toContain('"public_flag": "enabled"'); + expect(content).not.toContain("json-preview-secret"); + }); + + it("redacts bearer tokens from regular text previews before sending observations", async () => { + writeFileSync(join(root, "request.txt"), "Authorization: Bearer plaintext-token-value\n"); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, "request.txt"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain("Authorization: Bearer [REDACTED]"); + expect(content).not.toContain("plaintext-token-value"); + }); + + it("collapses multi-line PEM private-key blocks while keeping BEGIN/END markers", async () => { + const dashes = "-".repeat(5); + const rsaBegin = `${dashes}BEGIN RSA PRIVATE KEY${dashes}`; + const rsaEnd = `${dashes}END RSA PRIVATE KEY${dashes}`; + const sshBegin = `${dashes}BEGIN OPENSSH PRIVATE KEY${dashes}`; + const sshEnd = `${dashes}END OPENSSH PRIVATE KEY${dashes}`; + writeFileSync( + join(root, "id_rsa.txt"), + [ + rsaBegin, + "MIIEowIBAAKCAQEAuRFakeRsaBodyLine1ShouldNeverLeakToObservationPipeline", + "MoreFakeBase64BodyForRsaKeyMaterialThatMustStayRedacted", + "YetAnotherSecretLineOfBase64KeyContentNoOneShouldRead", + rsaEnd, + "", + sshBegin, + "b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtb25l", + "b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtdHdv", + sshEnd, + "", + ].join("\n"), + ); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, "id_rsa.txt"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain(rsaBegin); + expect(content).toContain(rsaEnd); + expect(content).toContain(sshBegin); + expect(content).toContain(sshEnd); + expect(content).toContain("[REDACTED]"); + expect(content).not.toContain("MIIEowIBAAKCAQEAuRFakeRsaBodyLine1"); + expect(content).not.toContain("MoreFakeBase64BodyForRsaKeyMaterial"); + expect(content).not.toContain("YetAnotherSecretLineOfBase64KeyContent"); + expect(content).not.toContain("b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtb25l"); + expect(content).not.toContain("b3BlbnNzaC1mYWtlLWtleS1ib2R5LWxpbmUtdHdv"); + }); + + it("redacts inline PEM blocks embedded in single-line JSON values", async () => { + const dashes = "-".repeat(5); + const pemBegin = `${dashes}BEGIN PRIVATE KEY${dashes}`; + const pemEnd = `${dashes}END PRIVATE KEY${dashes}`; + const inlinePem = `${pemBegin}\\nMIIEvgIBADANBgkqhkiG9w0FakeServiceAccountBody\\n${pemEnd}`; + writeFileSync( + join(root, "service-account.json"), + `{\n "type": "service_account",\n "private_key": "${inlinePem}",\n "client_email": "demo@example.com"\n}\n`, + ); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, "service-account.json"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain(pemBegin); + expect(content).toContain(pemEnd); + expect(content).toContain("[REDACTED]"); + expect(content).not.toContain("MIIEvgIBADANBgkqhkiG9w0FakeServiceAccountBody"); + expect(content).toContain('"client_email": "demo@example.com"'); + }); + + it("redacts standalone JWT-looking strings outside Bearer context", async () => { + const jwt = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + writeFileSync( + join(root, "notes.txt"), + ["session token below:", jwt, "end of token"].join("\n"), + ); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, "notes.txt"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain("[REDACTED]"); + expect(content).not.toContain(jwt); + expect(content).toContain("end of token"); + }); + + it("does not redact base64-looking words that are not three-segment JWTs of sufficient length", async () => { + const notJwt = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const shortThreeSegment = "eyJabc.def.ghi"; + expect(notJwt.length).toBe(62); + expect(shortThreeSegment.length).toBeLessThan(100); + writeFileSync( + join(root, "fixture.txt"), + ["random base64-ish word:", notJwt, "tiny segmented thing:", shortThreeSegment].join("\n"), + ); + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await w.flush(root, "fixture.txt"); + + expect(captured).toHaveLength(1); + const content = (captured[0].body as { data: { content: string } }).data.content; + expect(content).toContain(notJwt); + expect(content).toContain(shortThreeSegment); + expect(content).not.toContain("[REDACTED]"); + }); + + it("debounces rapid writes to a single observation", async () => { + const w = new FilesystemWatcher({ + roots: [root], + baseUrl: "http://localhost:3111", + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + w.start(); + try { + const target = join(root, "burst.md"); + writeFileSync(target, "1\n"); + writeFileSync(target, "2\n"); + writeFileSync(target, "3\n"); + writeFileSync(target, "4\n"); + await wait(900); + const hits = captured.filter((c) => + (c.body as { data: { files: string[] } }).data?.files?.[0] === "burst.md", + ); + expect(hits.length).toBeLessThanOrEqual(2); + } finally { + w.stop(); + } + }); +}); + +describe("configFromEnv", () => { + it("parses comma-separated dirs and ignore patterns", () => { + const cfg = configFromEnv({ + AGENTMEMORY_FS_WATCH_DIRS: " /a , /b ", + AGENTMEMORY_FS_WATCH_IGNORE: "foo$, ^bar", + AGENTMEMORY_URL: "http://localhost:3111", + AGENTMEMORY_SECRET: "tok", + AGENTMEMORY_PROJECT: "demo", + }); + expect(cfg.roots).toEqual(["/a", "/b"]); + expect(cfg.baseUrl).toBe("http://localhost:3111"); + expect(cfg.secret).toBe("tok"); + expect(cfg.project).toBe("demo"); + expect(cfg.ignorePatterns).toHaveLength(2); + expect(cfg.ignorePatterns[0].test("abcfoo")).toBe(true); + expect(cfg.ignorePatterns[1].test("barbaz")).toBe(true); + }); + + it("returns empty roots when the env var is missing", () => { + const cfg = configFromEnv({}); + expect(cfg.roots).toEqual([]); + }); +}); diff --git a/test/governance.test.ts b/test/governance.test.ts new file mode 100644 index 0000000..6390bbe --- /dev/null +++ b/test/governance.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerGovernanceFunction } from "../src/functions/governance.js"; +import { + getSearchIndex, + setIndexPersistence, +} from "../src/functions/search.js"; +import { memoryToObservation } from "../src/state/memory-utils.js"; +import type { Memory, AuditEntry } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeMemory(id: string, type: Memory["type"] = "pattern"): Memory { + return { + id, + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type, + title: `Memory ${id}`, + content: `Content for ${id}`, + concepts: ["test"], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + }; +} + +describe("Governance Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerGovernanceFunction(sdk as never, kv as never); + + await kv.set("mem:memories", "mem_1", makeMemory("mem_1", "pattern")); + await kv.set("mem:memories", "mem_2", makeMemory("mem_2", "bug")); + await kv.set("mem:memories", "mem_3", makeMemory("mem_3", "pattern")); + }); + + it("governance-delete removes specified memories", async () => { + const result = (await sdk.trigger("mem::governance-delete", { + memoryIds: ["mem_1"], + reason: "outdated", + })) as { success: boolean; deleted: number; total: number }; + + expect(result.success).toBe(true); + expect(result.deleted).toBe(1); + expect(result.total).toBe(1); + + const remaining = await kv.list("mem:memories"); + expect(remaining.length).toBe(2); + }); + + it("governance-delete handles non-existent IDs gracefully", async () => { + const result = (await sdk.trigger("mem::governance-delete", { + memoryIds: ["nonexistent_1", "nonexistent_2"], + })) as { success: boolean; deleted: number; total: number }; + + expect(result.success).toBe(true); + expect(result.deleted).toBe(0); + expect(result.total).toBe(2); + + const remaining = await kv.list("mem:memories"); + expect(remaining.length).toBe(3); + }); + + it("governance-bulk deletes by type filter", async () => { + const result = (await sdk.trigger("mem::governance-bulk", { + type: ["pattern"], + })) as { success: boolean; deleted: number }; + + expect(result.success).toBe(true); + expect(result.deleted).toBe(2); + + const remaining = await kv.list("mem:memories"); + expect(remaining.length).toBe(1); + expect(remaining[0].type).toBe("bug"); + }); + + it("governance-bulk respects dryRun", async () => { + const result = (await sdk.trigger("mem::governance-bulk", { + type: ["pattern"], + dryRun: true, + })) as { success: boolean; dryRun: boolean; wouldDelete: number; ids: string[] }; + + expect(result.success).toBe(true); + expect(result.dryRun).toBe(true); + expect(result.wouldDelete).toBe(2); + expect(result.ids).toContain("mem_1"); + expect(result.ids).toContain("mem_3"); + + const remaining = await kv.list("mem:memories"); + expect(remaining.length).toBe(3); + }); + + // Delete paths must tear down the BM25 index entry and trigger an + // IndexPersistence save so a hard process exit can't restore a stale + // snapshot at next boot. + describe("search index cleanup on delete", () => { + function indexedObs(id: string, title: string) { + return memoryToObservation({ + id, + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type: "fact", + title, + content: title, + concepts: [], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + }); + } + + function mockPersistence() { + return { + scheduleSave: vi.fn(), + save: vi.fn(async () => {}), + }; + } + + beforeEach(() => { + // SearchIndex is a module-level singleton — wipe it so cases + // don't bleed into each other. + getSearchIndex().clear(); + setIndexPersistence(null); + }); + + // The persistence singleton is module-scoped; without this reset + // the last test's mock would leak into sibling tests in the outer + // suite and trigger unexpected spy invocations. + afterEach(() => { + setIndexPersistence(null); + }); + + it("governance-delete removes the memory from the search index", async () => { + getSearchIndex().add(indexedObs("mem_1", "alpha")); + getSearchIndex().add(indexedObs("mem_2", "beta")); + expect(getSearchIndex().has("mem_1")).toBe(true); + + await sdk.trigger("mem::governance-delete", { + memoryIds: ["mem_1"], + }); + + expect(getSearchIndex().has("mem_1")).toBe(false); + expect(getSearchIndex().has("mem_2")).toBe(true); + }); + + it("governance-delete flushes persistence immediately", async () => { + const persistence = mockPersistence(); + setIndexPersistence(persistence); + getSearchIndex().add(indexedObs("mem_1", "alpha")); + + await sdk.trigger("mem::governance-delete", { memoryIds: ["mem_1"] }); + + // Delete paths must use the synchronous save (not the debounced + // scheduleSave) so a process exit immediately after delete can't + // resurrect the entry on next boot. + expect(persistence.save).toHaveBeenCalled(); + }); + + it("governance-delete skips persistence flush when nothing was deleted", async () => { + const persistence = mockPersistence(); + setIndexPersistence(persistence); + + await sdk.trigger("mem::governance-delete", { + memoryIds: ["nonexistent_999"], + }); + + expect(persistence.save).not.toHaveBeenCalled(); + }); + + it("governance-bulk removes deleted memories from the search index", async () => { + getSearchIndex().add(indexedObs("mem_1", "alpha")); + getSearchIndex().add(indexedObs("mem_2", "beta")); + getSearchIndex().add(indexedObs("mem_3", "gamma")); + + await sdk.trigger("mem::governance-bulk", { type: ["pattern"] }); + + // mem_1 and mem_3 are type "pattern" per the outer beforeEach. + expect(getSearchIndex().has("mem_1")).toBe(false); + expect(getSearchIndex().has("mem_3")).toBe(false); + expect(getSearchIndex().has("mem_2")).toBe(true); + }); + + it("governance-bulk flushes persistence immediately", async () => { + const persistence = mockPersistence(); + setIndexPersistence(persistence); + getSearchIndex().add(indexedObs("mem_1", "alpha")); + + await sdk.trigger("mem::governance-bulk", { type: ["pattern"] }); + + expect(persistence.save).toHaveBeenCalled(); + }); + }); + + it("audit-query returns audit entries", async () => { + await sdk.trigger("mem::governance-delete", { + memoryIds: ["mem_1"], + reason: "cleanup", + }); + + const entries = (await sdk.trigger("mem::audit-query", {})) as AuditEntry[]; + + expect(entries.length).toBe(1); + expect(entries[0].operation).toBe("delete"); + expect(entries[0].functionId).toBe("mem::governance-delete"); + }); +}); diff --git a/test/graph-retrieval.test.ts b/test/graph-retrieval.test.ts new file mode 100644 index 0000000..2eb3f92 --- /dev/null +++ b/test/graph-retrieval.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { GraphRetrieval } from "../src/functions/graph-retrieval.js"; +import type { GraphNode, GraphEdge } from "../src/types.js"; + +function mockKV( + nodes: GraphNode[] = [], + edges: GraphEdge[] = [], +) { + const store = new Map>(); + const nodesMap = new Map(); + for (const n of nodes) nodesMap.set(n.id, n); + store.set("mem:graph:nodes", nodesMap); + + const edgesMap = new Map(); + for (const e of edges) edgesMap.set(e.id, e); + store.set("mem:graph:edges", edgesMap); + + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeNode( + id: string, + name: string, + type: GraphNode["type"] = "concept", + obsIds: string[] = ["obs_1"], +): GraphNode { + return { + id, + type, + name, + properties: {}, + sourceObservationIds: obsIds, + createdAt: new Date().toISOString(), + }; +} + +function makeEdge( + id: string, + sourceNodeId: string, + targetNodeId: string, + type: GraphEdge["type"] = "related_to", + weight = 0.8, +): GraphEdge { + return { + id, + type, + sourceNodeId, + targetNodeId, + weight, + sourceObservationIds: ["obs_1"], + createdAt: new Date().toISOString(), + tcommit: new Date().toISOString(), + isLatest: true, + }; +} + +describe("GraphRetrieval", () => { + it("finds entities by name", async () => { + const nodes = [ + makeNode("n1", "React", "library", ["obs_1"]), + makeNode("n2", "Vue", "library", ["obs_2"]), + ]; + const kv = mockKV(nodes, []); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["React"]); + expect(results.length).toBeGreaterThan(0); + expect(results[0].obsId).toBe("obs_1"); + }); + + it("finds entities by partial name match", async () => { + const nodes = [makeNode("n1", "auth-middleware", "function", ["obs_1"])]; + const kv = mockKV(nodes, []); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["auth"]); + expect(results.length).toBeGreaterThan(0); + }); + + it("traverses graph edges to find related observations", async () => { + const nodes = [ + makeNode("n1", "React", "library", ["obs_1"]), + makeNode("n2", "Component", "concept", ["obs_2"]), + ]; + const edges = [makeEdge("e1", "n1", "n2", "uses")]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["React"], 2); + const obsIds = results.map((r) => r.obsId); + expect(obsIds).toContain("obs_1"); + expect(obsIds).toContain("obs_2"); + }); + + it("returns empty for no matches", async () => { + const kv = mockKV([], []); + const retrieval = new GraphRetrieval(kv as never); + const results = await retrieval.searchByEntities(["nonexistent"]); + expect(results).toEqual([]); + }); + + it("expands from existing chunks", async () => { + const nodes = [ + makeNode("n1", "auth.ts", "file", ["obs_1"]), + makeNode("n2", "jwt", "concept", ["obs_2"]), + ]; + const edges = [makeEdge("e1", "n1", "n2", "uses")]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.expandFromChunks(["obs_1"]); + const obsIds = results.map((r) => r.obsId); + expect(obsIds).toContain("obs_2"); + }); + + it("does not duplicate already-seen observations in expansion", async () => { + const nodes = [makeNode("n1", "file.ts", "file", ["obs_1", "obs_2"])]; + const kv = mockKV(nodes, []); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.expandFromChunks(["obs_1"]); + const obsIds = results.map((r) => r.obsId); + expect(obsIds).not.toContain("obs_1"); + }); + + it("performs temporal query - current state", async () => { + const nodes = [makeNode("n1", "Alice", "person", ["obs_1"])]; + const edges = [ + makeEdge("e1", "n1", "n1", "located_in" as any, 0.9), + { + ...makeEdge("e2", "n1", "n1", "located_in" as any, 0.9), + tvalid: "2024-06-01", + isLatest: true, + }, + ]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const result = await retrieval.temporalQuery("Alice"); + expect(result.entity).toBeDefined(); + expect(result.entity!.name).toBe("Alice"); + expect(result.currentState.length).toBeGreaterThan(0); + }); + + it("returns null entity for unknown name", async () => { + const kv = mockKV([], []); + const retrieval = new GraphRetrieval(kv as never); + const result = await retrieval.temporalQuery("Unknown"); + expect(result.entity).toBeNull(); + }); + + it("scores closer paths higher", async () => { + const nodes = [ + makeNode("n1", "React", "library", ["obs_1"]), + makeNode("n2", "Hook", "concept", ["obs_2"]), + makeNode("n3", "State", "concept", ["obs_3"]), + ]; + const edges = [ + makeEdge("e1", "n1", "n2", "uses", 0.9), + makeEdge("e2", "n2", "n3", "related_to", 0.8), + ]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["React"], 3); + const directScore = results.find((r) => r.obsId === "obs_1")?.score ?? 0; + const indirectScore = results.find((r) => r.obsId === "obs_3")?.score ?? 0; + expect(directScore).toBeGreaterThan(indirectScore); + }); + + // Dijkstra path selection (#328). The BFS implementation this + // replaced visited a node via its first-discovered path regardless + // of edge weight. Dijkstra picks the highest-weight (lowest + // 1/weight cost) path, so a one-hop weak edge no longer beats a + // two-hop chain of strong edges to the same node. + it("picks the weight-optimal path under Dijkstra, not the edge-count-shortest one (#328)", async () => { + const nodes = [ + makeNode("n1", "Start", "concept", ["obs_start"]), + makeNode("n2", "Mid", "concept", ["obs_mid"]), + makeNode("n3", "End", "concept", ["obs_end"]), + ]; + const edges = [ + // Direct n1 → n3 path with a weak edge. BFS would prefer this. + makeEdge("e_direct", "n1", "n3", "related_to", 0.15), + // Two-hop chain n1 → n2 → n3 with strong edges. Total cost + // (1/0.9) + (1/0.9) ≈ 2.22, vs direct 1/0.15 ≈ 6.67. + // Dijkstra picks the chain. + makeEdge("e_strong_a", "n1", "n2", "related_to", 0.9), + makeEdge("e_strong_b", "n2", "n3", "related_to", 0.9), + ]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["Start"], 3); + const endResult = results.find((r) => r.obsId === "obs_end"); + expect(endResult).toBeDefined(); + // Path is [Start → Mid → End] (length 3) — Dijkstra picked the + // chain of two strong edges over the direct weak one. + expect(endResult!.pathLength).toBe(3); + expect(endResult!.graphContext).toContain("Mid"); + }); + + it("handles disconnected nodes without crashing", async () => { + const nodes = [ + makeNode("n1", "A", "concept", ["obs_a"]), + makeNode("n2", "B", "concept", ["obs_b"]), + // n3 is unreachable from the matched node. + makeNode("n3", "Lonely", "concept", ["obs_lonely"]), + ]; + const edges = [makeEdge("e1", "n1", "n2", "related_to", 0.7)]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["A"], 5); + expect(results.find((r) => r.obsId === "obs_a")).toBeDefined(); + expect(results.find((r) => r.obsId === "obs_b")).toBeDefined(); + expect(results.find((r) => r.obsId === "obs_lonely")).toBeUndefined(); + }); + + it("clamps near-zero edge weights without dividing by zero", async () => { + const nodes = [ + makeNode("n1", "Anchor", "concept", ["obs_anchor"]), + makeNode("n2", "Weak", "concept", ["obs_weak"]), + ]; + // weight: 0 is malformed but we shouldn't crash on it; the clamp + // floor at 0.01 means traversal completes with a very high cost + // rather than throwing or producing Infinity. + const edges = [makeEdge("e1", "n1", "n2", "related_to", 0)]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["Anchor"], 2); + const weak = results.find((r) => r.obsId === "obs_weak"); + expect(weak).toBeDefined(); + expect(Number.isFinite(weak!.score)).toBe(true); + }); + + it("scores startNode observations at 1.0 via the fallback path, not 0.5 via the path-scoring loop (#328 review)", async () => { + // Regression for a bug surfaced by inline review on #463: if the + // traversal includes a length-1 path for the startNode itself, + // the generic path-scoring loop in searchByEntities computes + // avgWeight=0.5 (empty edgeWeights → fallback) and pathLength=1, + // yielding score=0.5, then marks the obs as visited. The + // dedicated score=1.0 fallback loop for startNode obs is then + // skipped via the visitedObs guard — dead code. + const nodes = [ + makeNode("n1", "React", "library", ["obs_root"]), + makeNode("n2", "Hook", "concept", ["obs_neighbor"]), + ]; + const edges = [makeEdge("e1", "n1", "n2", "uses", 0.8)]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["React"], 2); + const root = results.find((r) => r.obsId === "obs_root"); + expect(root).toBeDefined(); + expect(root!.score).toBe(1.0); + expect(root!.pathLength).toBe(0); + }); + + it("respects maxDepth bound (Dijkstra stops at edge-count depth)", async () => { + // Chain n1 -> n2 -> n3 -> n4. With maxDepth=2 we should reach n3 + // but not n4 — edge-count semantics preserved from the old BFS. + const nodes = [ + makeNode("n1", "Start", "concept", ["obs_1"]), + makeNode("n2", "Hop1", "concept", ["obs_2"]), + makeNode("n3", "Hop2", "concept", ["obs_3"]), + makeNode("n4", "Hop3", "concept", ["obs_4"]), + ]; + const edges = [ + makeEdge("e1", "n1", "n2", "related_to", 0.8), + makeEdge("e2", "n2", "n3", "related_to", 0.8), + makeEdge("e3", "n3", "n4", "related_to", 0.8), + ]; + const kv = mockKV(nodes, edges); + const retrieval = new GraphRetrieval(kv as never); + + const results = await retrieval.searchByEntities(["Start"], 2); + expect(results.find((r) => r.obsId === "obs_3")).toBeDefined(); + expect(results.find((r) => r.obsId === "obs_4")).toBeUndefined(); + }); +}); diff --git a/test/graph.test.ts b/test/graph.test.ts new file mode 100644 index 0000000..da8b266 --- /dev/null +++ b/test/graph.test.ts @@ -0,0 +1,732 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerGraphFunction } from "../src/functions/graph.js"; +import type { + CompressedObservation, + GraphNode, + GraphEdge, + GraphQueryResult, +} from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +const mockProvider = { + name: "test", + compress: vi.fn().mockResolvedValue(` +src/index.ts +typescript + + + +`), + summarize: vi.fn(), +}; + +const testObs: CompressedObservation = { + id: "obs_1", + sessionId: "ses_1", + timestamp: "2026-02-01T10:00:00Z", + type: "file_edit", + title: "Edit index file", + facts: ["Modified main function"], + narrative: "Updated index.ts with main function", + concepts: ["typescript", "entry-point"], + files: ["src/index.ts"], + importance: 7, +}; + +describe("Graph Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + vi.clearAllMocks(); + registerGraphFunction(sdk as never, kv as never, mockProvider as never); + }); + + it("graph-extract creates nodes and edges from XML response", async () => { + const result = (await sdk.trigger("mem::graph-extract", { + observations: [testObs], + })) as { success: boolean; nodesAdded: number; edgesAdded: number }; + + expect(result.success).toBe(true); + expect(result.nodesAdded).toBe(2); + expect(result.edgesAdded).toBe(1); + + const nodes = await kv.list("mem:graph:nodes"); + expect(nodes.length).toBe(2); + expect(nodes.find((n) => n.name === "src/index.ts")).toBeDefined(); + expect(nodes.find((n) => n.name === "main")).toBeDefined(); + + const edges = await kv.list("mem:graph:edges"); + expect(edges.length).toBe(1); + expect(edges[0].type).toBe("uses"); + }); + + it("graph-extract accepts self-closing entity tags", async () => { + mockProvider.compress.mockResolvedValueOnce(` + +typescript + + + +`); + + const result = (await sdk.trigger("mem::graph-extract", { + observations: [testObs], + })) as { success: boolean; nodesAdded: number; edgesAdded: number }; + + expect(result.success).toBe(true); + expect(result.nodesAdded).toBe(2); + expect(result.edgesAdded).toBe(1); + + const nodes = await kv.list("mem:graph:nodes"); + expect(nodes.some((n) => n.name === "src/index.ts")).toBe(true); + expect(nodes.some((n) => n.name === "main")).toBe(true); + + const edges = await kv.list("mem:graph:edges"); + expect(edges).toHaveLength(1); + expect(edges[0].type).toBe("uses"); + }); + + it("graph-extract tolerates reordered attributes (#635)", async () => { + // Codex CLI's LLM tends to emit attribute order name→type and + // source→target→type rather than the hard-coded type-first / + // type/source/target/weight sequence the old parser required. + mockProvider.compress.mockResolvedValueOnce(` + +typescript + + + +`); + + const result = (await sdk.trigger("mem::graph-extract", { + observations: [testObs], + })) as { success: boolean; nodesAdded: number; edgesAdded: number }; + + expect(result.success).toBe(true); + expect(result.nodesAdded).toBe(2); + expect(result.edgesAdded).toBe(1); + + const nodes = await kv.list("mem:graph:nodes"); + expect(nodes.find((n) => n.name === "src/index.ts")?.type).toBe("file"); + expect(nodes.find((n) => n.name === "main")?.type).toBe("function"); + + const edges = await kv.list("mem:graph:edges"); + expect(edges).toHaveLength(1); + expect(edges[0].type).toBe("uses"); + expect(edges[0].weight).toBeCloseTo(0.9, 5); + }); + + it("graph-query with search returns matching nodes", async () => { + await sdk.trigger("mem::graph-extract", { observations: [testObs] }); + + const result = (await sdk.trigger("mem::graph-query", { + query: "index", + })) as GraphQueryResult; + + expect(result.nodes.length).toBeGreaterThanOrEqual(1); + expect(result.nodes.some((n) => n.name.includes("index"))).toBe(true); + }); + + it("graph-query with startNodeId does BFS traversal", async () => { + await sdk.trigger("mem::graph-extract", { observations: [testObs] }); + + const nodes = await kv.list("mem:graph:nodes"); + const fileNode = nodes.find((n) => n.name === "src/index.ts")!; + + const result = (await sdk.trigger("mem::graph-query", { + startNodeId: fileNode.id, + maxDepth: 2, + })) as GraphQueryResult; + + expect(result.nodes.length).toBeGreaterThanOrEqual(1); + expect(result.edges.length).toBeGreaterThanOrEqual(1); + expect(result.depth).toBe(2); + }); + + it("graph-stats returns counts by type", async () => { + await sdk.trigger("mem::graph-extract", { observations: [testObs] }); + + const result = (await sdk.trigger("mem::graph-stats", {})) as { + totalNodes: number; + totalEdges: number; + nodesByType: Record; + edgesByType: Record; + }; + + expect(result.totalNodes).toBe(2); + expect(result.totalEdges).toBe(1); + expect(result.nodesByType.file).toBe(1); + expect(result.nodesByType.function).toBe(1); + expect(result.edgesByType.uses).toBe(1); + }); + + it("graph-extract returns error for empty observations", async () => { + const result = (await sdk.trigger("mem::graph-extract", { + observations: [], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("No observations"); + }); + + // #753: an unbounded {} body used to materialize every node+edge in + // one payload, which exceeded the iii state response channel on + // large corpora (11k+ nodes) and returned HTTP 500 "Invocation + // stopped". The fix caps the page at DEFAULT_GRAPH_QUERY_LIMIT (500) + // and surfaces totalNodes / totalEdges so callers know it was + // truncated. + it("caps an unbounded graph-query body to a default page and reports totals", async () => { + // Seed a graph with more nodes than the default page size. + const NODE_COUNT = 1200; + for (let i = 0; i < NODE_COUNT; i++) { + const node: GraphNode = { + id: `n_${i.toString().padStart(4, "0")}`, + type: "concept", + name: `node-${i}`, + properties: {}, + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + observationCount: 1, + } as GraphNode; + await kv.set("mem:graph:nodes", node.id, node); + } + // A few edges among the first 50 nodes so high-degree ranking has + // something to grade. + for (let i = 0; i < 50; i++) { + const edge: GraphEdge = { + id: `e_${i}`, + type: "related_to", + sourceNodeId: `n_${i.toString().padStart(4, "0")}`, + targetNodeId: `n_${((i + 1) % 50).toString().padStart(4, "0")}`, + weight: 1, + evidence: [], + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + } as GraphEdge; + await kv.set("mem:graph:edges", edge.id, edge); + } + + // Post-#814 the empty-body path reads the snapshot exclusively. + // Backfill the snapshot from the seeded data first. + await sdk.trigger("mem::graph-snapshot-rebuild", { force: true }); + + const unbounded = (await sdk.trigger( + "mem::graph-query", + {}, + )) as GraphQueryResult; + + expect(unbounded.totalNodes).toBe(NODE_COUNT); + expect(unbounded.nodes.length).toBe(500); + expect(unbounded.truncated).toBe(true); + expect(unbounded.limit).toBe(500); + expect(unbounded.offset).toBe(0); + // The 50 connected nodes should be on the first page since the + // default ranks by degree. + const connectedOnPage = unbounded.nodes.filter((n) => /^n_00[0-4]\d$/.test(n.id)); + expect(connectedOnPage.length).toBe(50); + }); + + it("honors limit and offset for paged graph-query traversal", async () => { + for (let i = 0; i < 50; i++) { + const node: GraphNode = { + id: `p_${i.toString().padStart(3, "0")}`, + type: "concept", + name: `node-${i}`, + properties: {}, + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + observationCount: 1, + } as GraphNode; + await kv.set("mem:graph:nodes", node.id, node); + } + + await sdk.trigger("mem::graph-snapshot-rebuild", { force: true }); + + const page1 = (await sdk.trigger("mem::graph-query", { + limit: 10, + offset: 0, + })) as GraphQueryResult; + const page2 = (await sdk.trigger("mem::graph-query", { + limit: 10, + offset: 10, + })) as GraphQueryResult; + + expect(page1.nodes.length).toBe(10); + expect(page2.nodes.length).toBe(10); + expect(page1.totalNodes).toBe(50); + expect(page2.totalNodes).toBe(50); + expect(page1.truncated).toBe(true); + // The two pages must not overlap. + const overlap = page1.nodes.filter((n) => + page2.nodes.some((p) => p.id === n.id), + ); + expect(overlap.length).toBe(0); + }); + + it("clamps an explicit limit above the cap to the cap value", async () => { + for (let i = 0; i < 10; i++) { + await kv.set("mem:graph:nodes", `c_${i}`, { + id: `c_${i}`, + type: "concept", + name: `n-${i}`, + properties: {}, + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + observationCount: 1, + }); + } + + await sdk.trigger("mem::graph-snapshot-rebuild", { force: true }); + + const huge = (await sdk.trigger("mem::graph-query", { + limit: 999999, + })) as GraphQueryResult; + expect(huge.limit).toBeLessThanOrEqual(5000); + expect(huge.nodes.length).toBe(10); + expect(huge.truncated).toBe(false); + }); + + it("paginate excludes edges whose endpoints fall outside the page", async () => { + for (let i = 0; i < 60; i++) { + await kv.set("mem:graph:nodes", `x_${i.toString().padStart(3, "0")}`, { + id: `x_${i.toString().padStart(3, "0")}`, + type: "concept", + name: `n-${i}`, + properties: {}, + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + observationCount: 1, + }); + } + // Make the first 10 nodes a tightly connected cluster so they + // rank highest by degree and land on the page deterministically. + for (let i = 0; i < 10; i++) { + const next = (i + 1) % 10; + await kv.set("mem:graph:edges", `cluster_${i}`, { + id: `cluster_${i}`, + type: "related_to", + sourceNodeId: `x_${i.toString().padStart(3, "0")}`, + targetNodeId: `x_${next.toString().padStart(3, "0")}`, + weight: 1, + evidence: [], + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + }); + } + // Cross-page edge: source in the high-degree cluster (on page), + // target is an isolated node (degree 1; cluster nodes have + // degree 2 so the target ranks below the cap). + await kv.set("mem:graph:edges", "cross", { + id: "cross", + type: "related_to", + sourceNodeId: "x_005", + targetNodeId: "x_055", + weight: 1, + evidence: [], + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + }); + + await sdk.trigger("mem::graph-snapshot-rebuild", { force: true }); + + const page = (await sdk.trigger("mem::graph-query", { + limit: 10, + offset: 0, + })) as GraphQueryResult; + // The cross-page edge should not appear in the page response — + // otherwise the viewer renders a dangling line to a node it + // doesn't have. + expect(page.edges.find((e) => e.id === "cross")).toBeUndefined(); + // Cluster edges among page nodes ARE present. + expect(page.edges.filter((e) => e.id.startsWith("cluster_")).length).toBe(10); + // totalEdges counts every edge in the full result universe. + expect(page.totalEdges).toBe(11); + }); + + // #814: precomputed snapshot path. The viewer-tab default-cap query + // and graph-stats both have to work at 75K-node scale where the + // full kv.list enumeration exceeds the iii invocation budget. + describe("snapshot cache (#814)", () => { + async function seed(nodeCount: number, edgeCount: number) { + for (let i = 0; i < nodeCount; i++) { + await kv.set("mem:graph:nodes", `n_${i}`, { + id: `n_${i}`, + type: i % 3 === 0 ? "file" : "function", + name: `node-${i}`, + properties: {}, + sourceObservationIds: [`obs_${i}`], + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + observationCount: 1, + stale: false, + }); + } + for (let i = 0; i < edgeCount; i++) { + const src = `n_${i % nodeCount}`; + const dst = `n_${(i + 1) % nodeCount}`; + await kv.set("mem:graph:edges", `e_${i}`, { + id: `e_${i}`, + type: i % 2 === 0 ? "uses" : "imports", + sourceNodeId: src, + targetNodeId: dst, + weight: 1, + evidence: [], + sourceObservationIds: [`obs_${i}`], + firstSeen: "2026-01-01T00:00:00Z", + lastSeen: "2026-01-01T00:00:00Z", + stale: false, + }); + } + } + + it("snapshot-rebuild persists top-degree subgraph + aggregate stats", async () => { + await seed(50, 100); + const result = (await sdk.trigger("mem::graph-snapshot-rebuild", { force: true })) as { + success: boolean; + totalNodes: number; + totalEdges: number; + topNodes: number; + topEdges: number; + }; + expect(result.success).toBe(true); + expect(result.totalNodes).toBe(50); + expect(result.totalEdges).toBe(100); + // 50 nodes is below the SNAPSHOT_TOP_NODES cap, so every node + // lands in the snapshot. + expect(result.topNodes).toBe(50); + + const snap = await kv.get<{ + version: number; + topNodes: unknown[]; + stats: { totalNodes: number; nodesByType: Record }; + }>("mem:graph:snapshot", "current"); + expect(snap).not.toBeNull(); + expect(snap!.version).toBe(1); + expect(snap!.stats.totalNodes).toBe(50); + // nodesByType reflects every type seen. + expect(snap!.stats.nodesByType["file"]).toBeGreaterThan(0); + expect(snap!.stats.nodesByType["function"]).toBeGreaterThan(0); + }); + + it("graph-query empty-body branch serves from snapshot once it exists", async () => { + await seed(20, 30); + await sdk.trigger("mem::graph-snapshot-rebuild", { force: true }); + + const result = (await sdk.trigger("mem::graph-query", {})) as GraphQueryResult; + expect(result.fromSnapshot).toBe(true); + expect(result.totalNodes).toBe(20); + expect(result.totalEdges).toBe(30); + }); + + it("graph-query nodeType filter respects snapshot type counts", async () => { + await seed(30, 0); + await sdk.trigger("mem::graph-snapshot-rebuild", { force: true }); + + const fileQuery = (await sdk.trigger("mem::graph-query", { + nodeType: "file", + })) as GraphQueryResult; + expect(fileQuery.fromSnapshot).toBe(true); + // 30 nodes, every 3rd is "file" → 10 files. + expect(fileQuery.totalNodes).toBe(10); + for (const n of fileQuery.nodes) { + expect(n.type).toBe("file"); + } + }); + + it("graph-stats returns from snapshot when not dirty", async () => { + await seed(15, 25); + await sdk.trigger("mem::graph-snapshot-rebuild", { force: true }); + + const stats = (await sdk.trigger("mem::graph-stats", {})) as { + totalNodes: number; + totalEdges: number; + fromSnapshot: boolean; + }; + expect(stats.fromSnapshot).toBe(true); + expect(stats.totalNodes).toBe(15); + expect(stats.totalEdges).toBe(25); + }); + + it("graph-extract updates snapshot inline (no kv.list, dirty stays false)", async () => { + // Post-#814 v2 the snapshot is updated incrementally on every + // extract — no dirty flag bounces. Test asserts that after an + // extract the snapshot reflects the new nodes/edges. + await sdk.trigger("mem::graph-extract", { observations: [testObs] }); + + const snap = await kv.get<{ + dirty: boolean; + stats: { totalNodes: number }; + }>("mem:graph:snapshot", "current"); + expect(snap?.dirty).toBe(false); + // testObs produces 2 nodes (src/index.ts, main) + 1 edge. + expect(snap?.stats.totalNodes).toBeGreaterThanOrEqual(1); + }); + + it("graph-extract maintains name-index for O(1) dedup on re-extract", async () => { + // First extract creates nodes. + await sdk.trigger("mem::graph-extract", { observations: [testObs] }); + const nameIndex = await kv.get( + "mem:graph:name-index", + "file|src/index.ts", + ); + expect(typeof nameIndex).toBe("string"); + + // Re-extract the same observation. With name-index lookup the + // existing node merges; no duplicates. + await sdk.trigger("mem::graph-extract", { observations: [testObs] }); + const nodes = await kv.list<{ name: string; type: string }>( + "mem:graph:nodes", + ); + const fileNodes = nodes.filter( + (n) => n.name === "src/index.ts" && n.type === "file", + ); + expect(fileNodes.length).toBe(1); + }); + + it("graph-stats returns empty envelope + warning when no snapshot exists", async () => { + // Seed nodes but never rebuild the snapshot — simulates a legacy + // corpus on a post-#814 upgrade. + await seed(5, 5); + + const stats = (await sdk.trigger("mem::graph-stats", {})) as { + totalNodes: number; + totalEdges: number; + fromSnapshot: boolean; + warning?: string; + }; + expect(stats.fromSnapshot).toBe(false); + expect(stats.totalNodes).toBe(0); + expect(stats.warning).toMatch(/snapshot-rebuild|graph\/reset/); + }); + + it("graph-reset clears state and writes empty snapshot", async () => { + await sdk.trigger("mem::graph-extract", { observations: [testObs] }); + const result = (await sdk.trigger("mem::graph-reset", {})) as { + success: boolean; + cleared: Record; + }; + expect(result.success).toBe(true); + + const snap = await kv.get<{ + stats: { totalNodes: number }; + }>("mem:graph:snapshot", "current"); + expect(snap?.stats.totalNodes).toBe(0); + }); + + it("graph-reset writes empty snapshot; legacy rows stay as orphans (#825)", async () => { + await sdk.trigger("mem::graph-extract", { observations: [testObs] }); + // Index entries exist after the extract. + const nameBefore = await kv.get( + "mem:graph:name-index", + "file|src/index.ts", + ); + expect(nameBefore).not.toBeNull(); + + await sdk.trigger("mem::graph-reset", {}); + + // Post-#825: reset is enumeration-free. It writes an empty + // snapshot; the legacy index rows remain on disk as orphans + // but are never read by any post-#816 code path (hot path + // reads only the snapshot, which is now empty). Asserting the + // visible behavior: snapshot empty, hot path returns empty. + const snap = await kv.get<{ + stats: { totalNodes: number; totalEdges: number }; + }>("mem:graph:snapshot", "current"); + expect(snap?.stats.totalNodes).toBe(0); + expect(snap?.stats.totalEdges).toBe(0); + }); + }); + + // CodeRabbit feedback: cover the timeout-budget fallback path and + // the oversized-corpus rebuild refusal. The hot path never enumerates + // any more, but the rebuild endpoint AND the BFS / query branches + // still call kv.list — both need explicit failure-mode tests. + describe("budget + tooLarge guards (#814 v2)", () => { + function slowKV(delayMs: number) { + const base = mockKV(); + return { + ...base, + list: async (scope: string): Promise => { + await new Promise((r) => setTimeout(r, delayMs)); + return base.list(scope); + }, + }; + } + + it("graph-query startNodeId returns warning envelope when enumeration exceeds budget", async () => { + const slow = slowKV(7000); // > LIVE_ENUMERATION_BUDGET_MS (6000ms) + const localSdk = mockSdk(); + registerGraphFunction(localSdk as never, slow as never, mockProvider as never); + + const result = (await localSdk.trigger("mem::graph-query", { + startNodeId: "n_missing", + })) as GraphQueryResult; + + expect(result.warning).toBeTruthy(); + expect(result.warning).toMatch(/budget|enumeration/i); + }, 10000); + + // CodeRabbit raised that slowKV(setTimeout) doesn't simulate a + // blocked event loop. The real production failure is iii rejecting + // the trigger with "Invocation stopped" after the worker dies + // (heartbeat starvation). A rejecting kv.list mock covers that + // catch-path directly without introducing a busy-wait that would + // also starve the budget timer and produce a flaky test. + function rejectingKV() { + const base = mockKV(); + return { + ...base, + list: async (_scope: string): Promise => { + throw new Error("Invocation stopped"); + }, + }; + } + + it("graph-query rejects-from-engine path returns warning envelope (worker-death simulation)", async () => { + const rejector = rejectingKV(); + const localSdk = mockSdk(); + registerGraphFunction( + localSdk as never, + rejector as never, + mockProvider as never, + ); + + const result = (await localSdk.trigger("mem::graph-query", { + startNodeId: "n_missing", + })) as GraphQueryResult; + + expect(result.warning).toBeTruthy(); + expect(result.nodes).toEqual([]); + }); + + it("graph-snapshot-rebuild refuses corpora past REBUILD_SAFE_NODE_CEILING", async () => { + // Direct-poke the mock store with > 25K node values so kv.list + // returns them without paying the per-set cost. Each node only + // needs id/type/name/stale=false for the rebuild path. + const localKv = mockKV(); + // Walk the implementation detail: mockKV stores entries in a + // Map under the scope key. Push directly to that map via the + // public `set` API in a tight loop. + const COUNT = 25001; + const sets: Array> = []; + for (let i = 0; i < COUNT; i++) { + sets.push( + localKv.set("mem:graph:nodes", `bn_${i}`, { + id: `bn_${i}`, + type: "concept", + name: `bulk-${i}`, + properties: {}, + sourceObservationIds: [], + createdAt: "2026-01-01T00:00:00Z", + stale: false, + }), + ); + } + await Promise.all(sets); + + const localSdk = mockSdk(); + registerGraphFunction(localSdk as never, localKv as never, mockProvider as never); + + const result = (await localSdk.trigger( + "mem::graph-snapshot-rebuild", + { force: true }, + )) as { success: boolean; tooLarge?: boolean; totalNodes?: number }; + expect(result.success).toBe(false); + expect(result.tooLarge).toBe(true); + expect(result.totalNodes).toBeGreaterThanOrEqual(25001); + }); + + // #825: new pre-flight refusal when no snapshot exists (signals + // legacy corpus that would crash on kv.list). force=true bypasses. + it("graph-snapshot-rebuild refuses on legacy corpus (no snapshot) without force", async () => { + const localKv = mockKV(); + // Seed nodes but never persist a snapshot → simulates a corpus + // built on a pre-#814 agentmemory. + await localKv.set("mem:graph:nodes", "legacy_n", { + id: "legacy_n", + type: "concept", + name: "legacy", + properties: {}, + sourceObservationIds: [], + createdAt: "2026-01-01T00:00:00Z", + stale: false, + }); + const localSdk = mockSdk(); + registerGraphFunction(localSdk as never, localKv as never, mockProvider as never); + + const result = (await localSdk.trigger( + "mem::graph-snapshot-rebuild", + {}, + )) as { success: boolean; legacyCorpus?: boolean; error?: string }; + expect(result.success).toBe(false); + expect(result.legacyCorpus).toBe(true); + expect(result.error).toMatch(/graph\/reset|force/); + }); + + it("graph-reset is enumeration-free (does not call kv.list)", async () => { + // Wrap the mock kv.list with a counter; assert it stays at 0 + // across a full reset cycle. + const localKv = mockKV(); + let listCalls = 0; + const baseList = localKv.list; + localKv.list = async (scope: string): Promise => { + listCalls += 1; + return baseList.call(localKv, scope) as Promise; + }; + const localSdk = mockSdk(); + registerGraphFunction(localSdk as never, localKv as never, mockProvider as never); + + const result = (await localSdk.trigger("mem::graph-reset", {})) as { + success: boolean; + }; + expect(result.success).toBe(true); + expect(listCalls).toBe(0); + }); + }); +}); diff --git a/test/health-thresholds.test.ts b/test/health-thresholds.test.ts new file mode 100644 index 0000000..6f918a9 --- /dev/null +++ b/test/health-thresholds.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { evaluateHealth } from "../src/health/thresholds.js"; +import type { HealthSnapshot } from "../src/types.js"; + +function snap(over: Partial = {}): HealthSnapshot { + return { + connectionState: "connected", + workers: [], + memory: { heapUsed: 0, heapTotal: 1, rss: 0, external: 0 }, + cpu: { userMicros: 0, systemMicros: 0, percent: 0 }, + eventLoopLagMs: 0, + uptimeSeconds: 1, + kvConnectivity: { status: "ok", latencyMs: 1 }, + status: "healthy", + alerts: [], + ...over, + }; +} + +describe("evaluateHealth memory severity", () => { + it("stays healthy when heap fills a tiny steady-state process (issue #158)", () => { + const s = snap({ + memory: { + heapUsed: 45 * 1024 * 1024, + heapTotal: 46 * 1024 * 1024, + rss: 120 * 1024 * 1024, + external: 0, + }, + }); + const { status, alerts, notes } = evaluateHealth(s); + expect(status).toBe("healthy"); + expect(alerts.find((a) => a.startsWith("memory_critical_"))).toBeUndefined(); + expect(alerts.find((a) => a.startsWith("memory_warn_"))).toBeUndefined(); + expect(alerts.find((a) => a.startsWith("memory_heap_tight_"))).toBeUndefined(); + expect(notes.find((n) => n.startsWith("memory_heap_tight_"))).toBeDefined(); + }); + + it("goes critical when heap ratio is high AND RSS is above the floor", () => { + const s = snap({ + memory: { + heapUsed: 970 * 1024 * 1024, + heapTotal: 1000 * 1024 * 1024, + rss: 1100 * 1024 * 1024, + external: 0, + }, + }); + const { status, alerts } = evaluateHealth(s); + expect(status).toBe("critical"); + expect(alerts.some((a) => a.startsWith("memory_critical_"))).toBe(true); + }); + + it("records heap_tight in the warn band when RSS is below the floor", () => { + const s = snap({ + memory: { + heapUsed: 85 * 1024 * 1024, + heapTotal: 100 * 1024 * 1024, + rss: 50 * 1024 * 1024, + external: 0, + }, + }); + const { status, alerts, notes } = evaluateHealth(s); + expect(status).toBe("healthy"); + expect(notes.some((n) => n.startsWith("memory_heap_tight_"))).toBe(true); + expect(alerts.some((a) => a.startsWith("memory_heap_tight_"))).toBe(false); + expect(alerts.some((a) => a.startsWith("memory_warn_"))).toBe(false); + expect(alerts.some((a) => a.startsWith("memory_critical_"))).toBe(false); + }); + + it("goes degraded when heap is above warn AND RSS is above the floor", () => { + const s = snap({ + memory: { + heapUsed: 850 * 1024 * 1024, + heapTotal: 1000 * 1024 * 1024, + rss: 900 * 1024 * 1024, + external: 0, + }, + }); + const { status, alerts } = evaluateHealth(s, { memoryRssFloorBytes: 800 * 1024 * 1024 }); + expect(status).toBe("degraded"); + expect(alerts.some((a) => a.startsWith("memory_warn_"))).toBe(true); + }); + + it("respects caller-supplied memoryRssFloorBytes", () => { + const s = snap({ + memory: { + heapUsed: 98, + heapTotal: 100, + rss: 50 * 1024 * 1024, + external: 0, + }, + }); + const loose = evaluateHealth(s, { memoryRssFloorBytes: 10 * 1024 * 1024 }); + expect(loose.status).toBe("critical"); + const strict = evaluateHealth(s, { memoryRssFloorBytes: 1024 * 1024 * 1024 }); + expect(strict.status).toBe("healthy"); + }); +}); diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts new file mode 100644 index 0000000..a382e2e --- /dev/null +++ b/test/helpers/mocks.ts @@ -0,0 +1,53 @@ +import { vi } from "vitest"; + +type Handler = (data: unknown) => Promise; + +export function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +export function mockSdk() { + const functions = new Map(); + return { + registerFunction: ( + idOrOpts: string | { id: string }, + handler: Handler, + _options?: Record, + ) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: vi.fn(), + trigger: async ( + idOrInput: + | string + | { function_id: string; payload: unknown; action?: unknown }, + data?: unknown, + ) => { + const id = + typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = + typeof idOrInput === "string" ? data : (idOrInput.payload as unknown); + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} diff --git a/test/hermes-plugin.test.ts b/test/hermes-plugin.test.ts new file mode 100644 index 0000000..87e7ed6 --- /dev/null +++ b/test/hermes-plugin.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; + +const expectedHermesHooks = [ + "prefetch", + "sync_turn", + "on_session_end", + "on_pre_compress", + "on_memory_write", + "system_prompt_block", +]; + +function readHermesPluginHooks(): string[] { + const manifest = readFileSync("integrations/hermes/plugin.yaml", "utf8"); + const hooks: string[] = []; + let inHooks = false; + + for (const line of manifest.split(/\r?\n/)) { + if (line.trim() === "hooks:") { + inHooks = true; + continue; + } + if (!inHooks) continue; + if (line.trim() === "") continue; + if (!line.startsWith(" ")) break; + + const match = line.match(/^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)\s*$/); + if (match) hooks.push(match[1]); + } + + return hooks; +} + +function isHermesLifecycleHook(methodName: string): boolean { + return ( + methodName === "prefetch" || + methodName === "sync_turn" || + methodName === "system_prompt_block" || + methodName.startsWith("on_") + ); +} + +function readAgentMemoryProviderHookMethods(): string[] { + const source = readFileSync("integrations/hermes/__init__.py", "utf8"); + const methods: string[] = []; + const providerMethodPattern = /^ def ([a-z_][a-z0-9_]*)\(/gm; + + for (const match of source.matchAll(providerMethodPattern)) { + const methodName = match[1]; + if (isHermesLifecycleHook(methodName)) methods.push(methodName); + } + + return methods; +} + +describe("Hermes plugin manifest", () => { + it("declares every implemented lifecycle hook", () => { + const declaredHooks = readHermesPluginHooks(); + const implementedHooks = readAgentMemoryProviderHookMethods(); + + expect([...declaredHooks].sort()).toEqual([...implementedHooks].sort()); + expect(declaredHooks).toEqual(expectedHermesHooks); + }); + + it("preloads AGENTMEMORY_URL default at import time", () => { + const source = readFileSync("integrations/hermes/__init__.py", "utf8"); + expect(source).toMatch( + /os\.environ\.setdefault\(\s*["']AGENTMEMORY_URL["']\s*,\s*DEFAULT_BASE_URL\s*\)/, + ); + }); +}); diff --git a/test/hook-project.test.ts b/test/hook-project.test.ts new file mode 100644 index 0000000..66e25f1 --- /dev/null +++ b/test/hook-project.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveProject } from "../src/hooks/_project.js"; + +describe("resolveProject — hook project basename resolver", () => { + const originalEnv = process.env.AGENTMEMORY_PROJECT_NAME; + + beforeEach(() => { + delete process.env.AGENTMEMORY_PROJECT_NAME; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AGENTMEMORY_PROJECT_NAME; + } else { + process.env.AGENTMEMORY_PROJECT_NAME = originalEnv; + } + }); + + it("AGENTMEMORY_PROJECT_NAME env wins over everything", () => { + process.env.AGENTMEMORY_PROJECT_NAME = "my-override"; + expect(resolveProject("/var/log")).toBe("my-override"); + expect(resolveProject(process.cwd())).toBe("my-override"); + }); + + it("trims whitespace on env override", () => { + process.env.AGENTMEMORY_PROJECT_NAME = " spaced "; + expect(resolveProject("/var/log")).toBe("spaced"); + }); + + it("ignores empty env override", () => { + process.env.AGENTMEMORY_PROJECT_NAME = " "; + const repoBasename = "agentmemory"; + expect(resolveProject(process.cwd())).toBe(repoBasename); + }); + + it("returns git toplevel basename when cwd is inside a repo", () => { + const top = resolveProject(process.cwd()); + expect(top).toBe("agentmemory"); + }); + + it("returns git toplevel basename from a nested subdir", () => { + const nested = join(process.cwd(), "src", "hooks"); + expect(resolveProject(nested)).toBe("agentmemory"); + }); + + it("falls back to basename(cwd) when not in a git repo", () => { + const dir = mkdtempSync(join(tmpdir(), "amem-noproj-")); + try { + expect(resolveProject(dir)).toBe(dir.split("/").pop()); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("defaults to process.cwd() when no cwd argument given", () => { + expect(resolveProject()).toBe("agentmemory"); + }); + + it("defaults to process.cwd() when cwd argument is empty", () => { + expect(resolveProject("")).toBe("agentmemory"); + expect(resolveProject(" ")).toBe("agentmemory"); + }); +}); diff --git a/test/hybrid-search.test.ts b/test/hybrid-search.test.ts new file mode 100644 index 0000000..811247a --- /dev/null +++ b/test/hybrid-search.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { HybridSearch } from "../src/state/hybrid-search.js"; +import { SearchIndex } from "../src/state/search-index.js"; +import type { CompressedObservation, EmbeddingProvider } from "../src/types.js"; + +function makeObs( + overrides: Partial = {}, +): CompressedObservation { + return { + id: "obs_1", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + type: "file_edit", + title: "Edit auth middleware", + subtitle: "JWT validation", + facts: ["Added token check"], + narrative: "Modified the auth middleware to validate JWT tokens", + concepts: ["authentication", "jwt"], + files: ["src/middleware/auth.ts"], + importance: 7, + ...overrides, + }; +} + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +describe("HybridSearch", () => { + let bm25: SearchIndex; + let kv: ReturnType; + + beforeEach(() => { + bm25 = new SearchIndex(); + kv = mockKV(); + }); + + it("returns BM25-only results when no vector index is provided", async () => { + const obs = makeObs({ id: "obs_1", sessionId: "ses_1" }); + bm25.add(obs); + await kv.set("mem:obs:ses_1", "obs_1", obs); + + const hybrid = new HybridSearch(bm25, null, null, kv as never); + const results = await hybrid.search("auth"); + + expect(results.length).toBe(1); + expect(results[0].observation.id).toBe("obs_1"); + expect(results[0].vectorScore).toBe(0); + expect(results[0].bm25Score).toBeGreaterThan(0); + }); + + it("returns empty results for no-match query", async () => { + const obs = makeObs({ id: "obs_1", sessionId: "ses_1" }); + bm25.add(obs); + await kv.set("mem:obs:ses_1", "obs_1", obs); + + const hybrid = new HybridSearch(bm25, null, null, kv as never); + const results = await hybrid.search("database"); + expect(results).toEqual([]); + }); + + it("combinedScore is derived from bm25Score when no vector index", async () => { + const obs = makeObs({ id: "obs_1", sessionId: "ses_1" }); + bm25.add(obs); + await kv.set("mem:obs:ses_1", "obs_1", obs); + + const hybrid = new HybridSearch(bm25, null, null, kv as never); + const results = await hybrid.search("auth"); + + expect(results[0].combinedScore).toBeGreaterThan(0); + expect(results[0].vectorScore).toBe(0); + expect(results[0].graphScore).toBe(0); + }); + + it("results are sorted by combinedScore descending", async () => { + const obs1 = makeObs({ + id: "obs_1", + sessionId: "ses_1", + title: "auth handler", + narrative: "auth auth auth module", + concepts: ["auth"], + }); + const obs2 = makeObs({ + id: "obs_2", + sessionId: "ses_1", + title: "database setup", + narrative: "auth connection config", + concepts: ["database"], + }); + bm25.add(obs1); + bm25.add(obs2); + await kv.set("mem:obs:ses_1", "obs_1", obs1); + await kv.set("mem:obs:ses_1", "obs_2", obs2); + + const hybrid = new HybridSearch(bm25, null, null, kv as never); + const results = await hybrid.search("auth"); + + expect(results.length).toBe(2); + expect(results[0].combinedScore).toBeGreaterThanOrEqual( + results[1].combinedScore, + ); + }); + + it("respects limit parameter", async () => { + for (let i = 0; i < 10; i++) { + const obs = makeObs({ + id: `obs_${i}`, + sessionId: "ses_1", + title: `auth feature ${i}`, + }); + bm25.add(obs); + await kv.set("mem:obs:ses_1", `obs_${i}`, obs); + } + + const hybrid = new HybridSearch(bm25, null, null, kv as never); + const results = await hybrid.search("auth", 3); + expect(results.length).toBe(3); + }); + + it("skips observations not found in KV", async () => { + const obs = makeObs({ id: "obs_missing", sessionId: "ses_1" }); + bm25.add(obs); + + const hybrid = new HybridSearch(bm25, null, null, kv as never); + const results = await hybrid.search("auth"); + expect(results).toEqual([]); + }); + + it("falls back to KV.memories when an indexed entry is a saved memory (#265)", async () => { + // mem::remember writes to KV.memories under the synthetic sessionId + // "memory" — the BM25 index sees that synthetic sessionId, but + // KV.observations("memory") never has anything. + const indexable = makeObs({ + id: "mem_abc", + sessionId: "memory", + title: "Test memory for search", + narrative: "Test memory for search", + concepts: ["test", "search"], + }); + bm25.add(indexable); + + const memory = { + id: "mem_abc", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "fact", + title: "Test memory for search", + content: "Test memory for search", + concepts: ["test", "search"], + files: [], + sessionIds: [], + strength: 7, + version: 1, + isLatest: true, + }; + await kv.set("mem:memories", "mem_abc", memory); + + const hybrid = new HybridSearch(bm25, null, null, kv as never); + const results = await hybrid.search("test memory search"); + + expect(results.length).toBe(1); + expect(results[0].observation.id).toBe("mem_abc"); + expect(results[0].observation.narrative).toBe("Test memory for search"); + expect(results[0].observation.concepts).toEqual(["test", "search"]); + }); +}); diff --git a/test/index-persistence.test.ts b/test/index-persistence.test.ts new file mode 100644 index 0000000..9297916 --- /dev/null +++ b/test/index-persistence.test.ts @@ -0,0 +1,793 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { IndexPersistence } from "../src/state/index-persistence.js"; +import { SearchIndex } from "../src/state/search-index.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import type { CompressedObservation } from "../src/types.js"; + +const BM25_SCOPE = "mem:index:bm25"; +const BM25_LEGACY_KEY = "data"; +const BM25_MANIFEST_KEY = "data:manifest"; +const VECTOR_LEGACY_KEY = "vectors"; +const VECTOR_MANIFEST_KEY = "vectors:manifest"; + +type TestIndexShardManifest = { + v: 1; + generation?: string; + shards: Array<{ scope: string; key: string; chars: number }>; + chars: number; +}; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +type MockKV = ReturnType; + +function makeObs( + overrides: Partial = {}, +): CompressedObservation { + return { + id: "obs_1", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + type: "file_edit", + title: "Edit auth middleware", + subtitle: "JWT validation", + facts: ["Added token check"], + narrative: "Modified the auth middleware to validate JWT tokens", + concepts: ["authentication", "jwt"], + files: ["src/middleware/auth.ts"], + importance: 7, + ...overrides, + }; +} + +function makeBm25(id: string, title: string): SearchIndex { + const bm25 = new SearchIndex(); + bm25.add(makeObs({ id, title, narrative: `${title} narrative` })); + return bm25; +} + +function makeVector(id = "obs_1"): VectorIndex { + const vector = new VectorIndex(); + vector.add(id, "ses_1", new Float32Array([0.1, 0.2, 0.3])); + return vector; +} + +async function getBm25Manifest(kv: MockKV): Promise { + const manifest = await kv.get( + BM25_SCOPE, + BM25_MANIFEST_KEY, + ); + expect(manifest).not.toBeNull(); + return manifest!; +} + +describe("IndexPersistence", () => { + let kv: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + kv = mockKV(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("saves and loads BM25 index round-trip", async () => { + const bm25 = new SearchIndex(); + bm25.add(makeObs({ id: "obs_1", title: "auth handler" })); + + const persistence = new IndexPersistence(kv as never, bm25, null); + await persistence.save(); + + const loaded = await persistence.load(); + expect(loaded.bm25).not.toBeNull(); + expect(loaded.bm25!.size).toBe(1); + const results = loaded.bm25!.search("auth"); + expect(results.length).toBe(1); + }); + + it("saves BM25 index shards outside the BM25 metadata scope", async () => { + const bm25 = new SearchIndex(); + bm25.add( + makeObs({ + id: "obs_1", + title: "auth handler ".repeat(40), + narrative: "JWT middleware validation ".repeat(40), + }), + ); + + const persistence = new IndexPersistence(kv as never, bm25, null, { + shardChars: 80, + createGeneration: () => "gen_bm25", + }); + await persistence.save(); + + const manifest = await getBm25Manifest(kv); + expect(manifest.generation).toBe("gen_bm25"); + expect(manifest.shards.length).toBeGreaterThan(1); + expect(manifest.shards[0].scope).toContain(":gen_bm25:"); + await expect(kv.get(BM25_SCOPE, BM25_LEGACY_KEY)).resolves.toBeNull(); + await expect( + kv.get(manifest.shards[0].scope, manifest.shards[0].key), + ).resolves.toEqual(expect.any(String)); + + const loaded = await persistence.load(); + expect(loaded.bm25).not.toBeNull(); + expect(loaded.bm25!.search("auth").length).toBe(1); + }); + + it("loads legacy monolithic BM25 and vector snapshots", async () => { + const bm25 = makeBm25("obs_1", "legacy auth handler"); + const vector = makeVector("obs_1"); + await kv.set(BM25_SCOPE, BM25_LEGACY_KEY, bm25.serialize()); + await kv.set(BM25_SCOPE, VECTOR_LEGACY_KEY, vector.serialize()); + + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + + expect(loaded.bm25).not.toBeNull(); + expect(loaded.bm25!.search("legacy").length).toBe(1); + expect(loaded.vector).not.toBeNull(); + expect(loaded.vector!.size).toBe(1); + }); + + it("fails closed instead of falling back when manifest reads fail", async () => { + const legacy = makeBm25("obs_legacy", "legacy stale snapshot"); + await kv.set(BM25_SCOPE, BM25_LEGACY_KEY, legacy.serialize()); + const failingKv = { + ...kv, + get: vi.fn(async (scope: string, key: string): Promise => { + if (scope === BM25_SCOPE && key === BM25_MANIFEST_KEY) { + throw new Error("manifest backend unavailable"); + } + return kv.get(scope, key); + }), + }; + + const loaded = await new IndexPersistence( + failingKv as never, + new SearchIndex(), + null, + ).load(); + + expect(loaded.bm25).toBeNull(); + }); + + it("fails closed when legacy snapshot reads fail", async () => { + const failingKv = { + ...kv, + get: vi.fn(async (scope: string, key: string): Promise => { + if (scope === BM25_SCOPE && key === BM25_LEGACY_KEY) { + throw new Error("legacy backend unavailable"); + } + return kv.get(scope, key); + }), + }; + + const loaded = await new IndexPersistence( + failingKv as never, + new SearchIndex(), + null, + ).load(); + + expect(loaded.bm25).toBeNull(); + }); + + it("loads sharded manifests that omit optional generation metadata", async () => { + const bm25 = makeBm25("obs_1", "deterministic shard auth"); + const serialized = bm25.serialize(); + const chunks = [serialized.slice(0, 50), serialized.slice(50)]; + await kv.set("mem:index:bm25:bm25:00000", "data", chunks[0]); + await kv.set("mem:index:bm25:bm25:00001", "data", chunks[1]); + await kv.set(BM25_SCOPE, BM25_MANIFEST_KEY, { + v: 1, + chars: serialized.length, + shards: [ + { + scope: "mem:index:bm25:bm25:00000", + key: "data", + chars: chunks[0].length, + }, + { + scope: "mem:index:bm25:bm25:00001", + key: "data", + chars: chunks[1].length, + }, + ], + }); + + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + + expect(loaded.bm25).not.toBeNull(); + expect(loaded.bm25!.search("deterministic").length).toBe(1); + }); + + it("saves and loads vector index round-trip", async () => { + const bm25 = new SearchIndex(); + const vector = makeVector(); + + const persistence = new IndexPersistence(kv as never, bm25, vector); + await persistence.save(); + + const loaded = await persistence.load(); + expect(loaded.vector).not.toBeNull(); + expect(loaded.vector!.size).toBe(1); + }); + + it("saves vector index shards outside the BM25 scope", async () => { + const bm25 = new SearchIndex(); + const vector = new VectorIndex(); + vector.add( + "obs_1", + "ses_1", + new Float32Array(Array.from({ length: 32 }, (_, i) => i)), + ); + + const persistence = new IndexPersistence(kv as never, bm25, vector, { + shardChars: 40, + createGeneration: () => "gen_vector", + }); + await persistence.save(); + + const manifest = await kv.get( + BM25_SCOPE, + VECTOR_MANIFEST_KEY, + ); + expect(manifest).not.toBeNull(); + expect(manifest!.generation).toBe("gen_vector"); + expect(manifest!.shards.length).toBeGreaterThan(1); + expect(manifest!.shards[0].scope).toContain(":gen_vector:"); + await expect(kv.get(BM25_SCOPE, VECTOR_LEGACY_KEY)).resolves.toBeNull(); + await expect( + kv.get(manifest!.shards[0].scope, manifest!.shards[0].key), + ).resolves.toEqual(expect.any(String)); + + const loaded = await persistence.load(); + expect(loaded.vector).not.toBeNull(); + expect(loaded.vector!.size).toBe(1); + }); + + it("persists empty vector snapshots so cleared vectors do not reload", async () => { + const previousBm25 = makeBm25("obs_old", "alpha previous snapshot"); + const previousVector = makeVector("obs_old"); + await new IndexPersistence(kv as never, previousBm25, previousVector, { + shardChars: 80, + createGeneration: () => "gen_old", + }).save(); + + const nextBm25 = makeBm25("obs_new", "bravo new snapshot"); + const emptyVector = new VectorIndex(); + await new IndexPersistence(kv as never, nextBm25, emptyVector, { + shardChars: 80, + createGeneration: () => "gen_empty", + }).save(); + + const vectorManifest = await kv.get( + BM25_SCOPE, + VECTOR_MANIFEST_KEY, + ); + expect(vectorManifest).not.toBeNull(); + expect(vectorManifest!.generation).toBe("gen_empty"); + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + expect(loaded.bm25!.search("bravo").length).toBe(1); + expect(loaded.vector).not.toBeNull(); + expect(loaded.vector!.size).toBe(0); + }); + + it("avoids one oversized state::set string payload for persisted indexes", async () => { + const maxStringPayloadChars = 80; + const bm25 = new SearchIndex(); + bm25.add( + makeObs({ + id: "obs_1", + title: "large persisted snapshot ".repeat(40), + narrative: "oversized state set reproduction ".repeat(40), + }), + ); + const vector = new VectorIndex(); + vector.add( + "obs_1", + "ses_1", + new Float32Array(Array.from({ length: 64 }, (_, i) => i / 10)), + ); + const guardedKv = { + ...kv, + set: vi.fn(async (scope: string, key: string, data: T): Promise => { + if ( + typeof data === "string" && + data.length > maxStringPayloadChars + ) { + throw new Error(`oversized state::set payload: ${scope}/${key}`); + } + return kv.set(scope, key, data); + }), + }; + + await new IndexPersistence(guardedKv as never, bm25, vector, { + shardChars: maxStringPayloadChars, + createGeneration: () => "gen_payload_limit", + }).save(); + + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + expect(loaded.bm25!.search("oversized").length).toBe(1); + expect(loaded.vector!.size).toBe(1); + }); + + it("falls back to the default shard size for fractional values below one", async () => { + const bm25 = makeBm25("obs_fraction", "fractional shard config"); + let newShardWrites = 0; + const guardedKv = { + ...kv, + set: vi.fn(async (scope: string, key: string, data: T): Promise => { + if (scope.includes(":gen_fraction:")) { + newShardWrites += 1; + if (newShardWrites > 3) { + throw new Error("fractional shard size caused zero-width shards"); + } + } + return kv.set(scope, key, data); + }), + }; + + await new IndexPersistence(guardedKv as never, bm25, null, { + shardChars: 0.5, + createGeneration: () => "gen_fraction", + }).save(); + + const manifest = await getBm25Manifest(kv); + expect(manifest.generation).toBe("gen_fraction"); + expect(manifest.shards.length).toBe(1); + expect(newShardWrites).toBe(1); + }); + + it("keeps the previous generation when a shard write fails before manifest commit", async () => { + const previous = makeBm25("obs_old", "alpha previous snapshot"); + await new IndexPersistence(kv as never, previous, null, { + shardChars: 80, + createGeneration: () => "gen_old", + }).save(); + const previousManifest = await getBm25Manifest(kv); + + let newShardWrites = 0; + const failingKv = { + ...kv, + set: vi.fn(async (scope: string, key: string, data: T): Promise => { + if (scope.includes(":gen_new:")) { + newShardWrites += 1; + if (newShardWrites === 2) throw new Error("shard write failed"); + } + return kv.set(scope, key, data); + }), + }; + + const next = makeBm25("obs_new", "bravo new snapshot"); + await new IndexPersistence(failingKv as never, next, null, { + shardChars: 80, + createGeneration: () => "gen_new", + }).save(); + + await expect(kv.get(BM25_SCOPE, BM25_MANIFEST_KEY)).resolves.toEqual( + previousManifest, + ); + await expect( + kv.get("mem:index:bm25:bm25:gen_new:00000", "data"), + ).resolves.toBeNull(); + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + expect(loaded.bm25!.search("alpha").length).toBe(1); + expect(loaded.bm25!.search("bravo").length).toBe(0); + }); + + it("keeps the previous generation when manifest set rejects before commit", async () => { + const previous = makeBm25("obs_old", "alpha previous snapshot"); + await new IndexPersistence(kv as never, previous, null, { + shardChars: 80, + createGeneration: () => "gen_old", + }).save(); + const previousManifest = await getBm25Manifest(kv); + + const failingKv = { + ...kv, + set: vi.fn(async (scope: string, key: string, data: T): Promise => { + if (scope === BM25_SCOPE && key === BM25_MANIFEST_KEY) { + throw new Error("manifest write failed"); + } + return kv.set(scope, key, data); + }), + }; + + const next = makeBm25("obs_new", "bravo new snapshot"); + await new IndexPersistence(failingKv as never, next, null, { + shardChars: 80, + createGeneration: () => "gen_new", + }).save(); + + await expect(kv.get(BM25_SCOPE, BM25_MANIFEST_KEY)).resolves.toEqual( + previousManifest, + ); + await expect( + kv.get("mem:index:bm25:bm25:gen_new:00000", "data"), + ).resolves.toBeNull(); + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + expect(loaded.bm25!.search("alpha").length).toBe(1); + expect(loaded.bm25!.search("bravo").length).toBe(0); + }); + + it("keeps a generation loadable when manifest set commits before rejecting", async () => { + const previous = makeBm25("obs_old", "alpha previous snapshot"); + await new IndexPersistence(kv as never, previous, null, { + shardChars: 80, + createGeneration: () => "gen_old", + }).save(); + + const failingKv = { + ...kv, + set: vi.fn(async (scope: string, key: string, data: T): Promise => { + if (scope === BM25_SCOPE && key === BM25_MANIFEST_KEY) { + await kv.set(scope, key, data); + throw new Error("manifest write timed out after commit"); + } + return kv.set(scope, key, data); + }), + }; + + const next = makeBm25("obs_new", "bravo new snapshot"); + await new IndexPersistence(failingKv as never, next, null, { + shardChars: 80, + createGeneration: () => "gen_new", + }).save(); + + const manifest = await getBm25Manifest(kv); + expect(manifest.generation).toBe("gen_new"); + await expect( + kv.get("mem:index:bm25:bm25:gen_new:00000", "data"), + ).resolves.toEqual(expect.any(String)); + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + expect(loaded.bm25!.search("bravo").length).toBe(1); + }); + + it("deletes a shard that committed before set rejected", async () => { + const previous = makeBm25("obs_old", "alpha previous snapshot"); + await new IndexPersistence(kv as never, previous, null, { + shardChars: 80, + createGeneration: () => "gen_old", + }).save(); + const previousManifest = await getBm25Manifest(kv); + + const failingKv = { + ...kv, + set: vi.fn(async (scope: string, key: string, data: T): Promise => { + if (scope === "mem:index:bm25:bm25:gen_new:00000") { + await kv.set(scope, key, data); + throw new Error("state::set timed out after commit"); + } + return kv.set(scope, key, data); + }), + }; + + const next = makeBm25("obs_new", "bravo new snapshot"); + await new IndexPersistence(failingKv as never, next, null, { + shardChars: 80, + createGeneration: () => "gen_new", + }).save(); + + await expect(kv.get(BM25_SCOPE, BM25_MANIFEST_KEY)).resolves.toEqual( + previousManifest, + ); + await expect( + kv.get("mem:index:bm25:bm25:gen_new:00000", "data"), + ).resolves.toBeNull(); + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + expect(loaded.bm25!.search("alpha").length).toBe(1); + expect(loaded.bm25!.search("bravo").length).toBe(0); + }); + + it("loads the new generation even when old generation cleanup fails", async () => { + const previous = makeBm25("obs_old", "alpha previous snapshot"); + await new IndexPersistence(kv as never, previous, null, { + shardChars: 80, + createGeneration: () => "gen_old", + }).save(); + + const cleanupKv = { + ...kv, + delete: vi.fn(async () => { + throw new Error("cleanup failed"); + }), + }; + const next = makeBm25("obs_new", "bravo new snapshot"); + await new IndexPersistence(cleanupKv as never, next, null, { + shardChars: 80, + createGeneration: () => "gen_new", + }).save(); + + const manifest = await getBm25Manifest(kv); + expect(manifest.generation).toBe("gen_new"); + expect(cleanupKv.delete).toHaveBeenCalled(); + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + expect(loaded.bm25!.search("bravo").length).toBe(1); + expect(loaded.bm25!.search("alpha").length).toBe(0); + }); + + it("keeps the previous vector generation when vector save fails after BM25 publish", async () => { + const previousBm25 = makeBm25("obs_old", "alpha previous snapshot"); + const previousVector = makeVector("obs_old"); + await new IndexPersistence(kv as never, previousBm25, previousVector, { + shardChars: 80, + createGeneration: () => "gen_old", + }).save(); + + const failingKv = { + ...kv, + set: vi.fn(async (scope: string, key: string, data: T): Promise => { + if (scope === BM25_SCOPE && key === VECTOR_MANIFEST_KEY) { + throw new Error("vector manifest write failed"); + } + return kv.set(scope, key, data); + }), + }; + const nextBm25 = makeBm25("obs_new", "bravo new snapshot"); + const nextVector = new VectorIndex(); + nextVector.add("obs_new", "ses_1", new Float32Array([0.4, 0.5, 0.6])); + + await new IndexPersistence(failingKv as never, nextBm25, nextVector, { + shardChars: 80, + createGeneration: () => "gen_new", + }).save(); + + await expect( + kv.get("mem:index:bm25:vectors:gen_new:00000", "data"), + ).resolves.toBeNull(); + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + expect(loaded.bm25!.search("bravo").length).toBe(1); + expect(loaded.vector!.size).toBe(1); + expect( + loaded.vector!.search(new Float32Array([0.1, 0.2, 0.3]))[0]?.obsId, + ).toBe("obs_old"); + }); + + it("fails closed when a manifest shard is missing", async () => { + const bm25 = makeBm25("obs_1", "alpha sharded snapshot"); + await new IndexPersistence(kv as never, bm25, null, { + shardChars: 80, + createGeneration: () => "gen_missing", + }).save(); + const manifest = await getBm25Manifest(kv); + await kv.delete(manifest.shards[0].scope, manifest.shards[0].key); + + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + + expect(loaded.bm25).toBeNull(); + }); + + it("fails closed when a manifest shard length mismatches", async () => { + const bm25 = makeBm25("obs_1", "alpha sharded snapshot"); + await new IndexPersistence(kv as never, bm25, null, { + shardChars: 80, + createGeneration: () => "gen_mismatch", + }).save(); + const manifest = await getBm25Manifest(kv); + const firstShard = manifest.shards[0]; + const chunk = await kv.get(firstShard.scope, firstShard.key); + await kv.set(firstShard.scope, firstShard.key, `${chunk}x`); + + const loaded = await new IndexPersistence( + kv as never, + new SearchIndex(), + null, + ).load(); + + expect(loaded.bm25).toBeNull(); + }); + + it("fails closed before reading invalid shard descriptors", async () => { + await kv.set(BM25_SCOPE, BM25_MANIFEST_KEY, { + v: 1, + chars: 10, + shards: [{ scope: "", key: "data", chars: 10 }], + }); + const guardedKv = { + ...kv, + get: vi.fn(async (scope: string, key: string): Promise => { + if (scope === "") { + throw new Error("invalid shard descriptor was read"); + } + return kv.get(scope, key); + }), + }; + + const loaded = await new IndexPersistence( + guardedKv as never, + new SearchIndex(), + null, + ).load(); + + expect(loaded.bm25).toBeNull(); + expect(guardedKv.get).not.toHaveBeenCalledWith("", "data"); + }); + + it("scheduleSave debounces multiple calls", async () => { + const bm25 = new SearchIndex(); + const persistence = new IndexPersistence(kv as never, bm25, null); + + persistence.scheduleSave(); + persistence.scheduleSave(); + persistence.scheduleSave(); + + await expect(kv.get(BM25_SCOPE, BM25_MANIFEST_KEY)).resolves.toBeNull(); + + vi.advanceTimersByTime(5000); + await vi.runAllTimersAsync(); + + const saved = await kv.get(BM25_SCOPE, BM25_MANIFEST_KEY); + expect(saved).not.toBeNull(); + }); + + it("stop clears the pending timer", async () => { + const bm25 = new SearchIndex(); + bm25.add(makeObs({ id: "obs_1", title: "auth handler" })); + const persistence = new IndexPersistence(kv as never, bm25, null); + + persistence.scheduleSave(); + persistence.stop(); + + vi.advanceTimersByTime(10000); + const saved = await kv.get(BM25_SCOPE, BM25_MANIFEST_KEY); + expect(saved).toBeNull(); + }); + + it("returns null indexes when nothing has been saved", async () => { + const bm25 = new SearchIndex(); + const persistence = new IndexPersistence(kv as never, bm25, null); + + const loaded = await persistence.load(); + expect(loaded.bm25).toBeNull(); + expect(loaded.vector).toBeNull(); + }); + + it("scheduled save swallows kv.set rejection without unhandledRejection (#204)", async () => { + const failingKv = { + ...mockKV(), + set: vi.fn(async () => { + const err = new Error( + "TIMEOUT: invocation timed out after 30000ms", + ) as Error & { code?: string; function_id?: string }; + err.code = "TIMEOUT"; + err.function_id = "state::set"; + throw err; + }), + }; + const bm25 = new SearchIndex(); + bm25.add(makeObs({ id: "obs_1", title: "auth handler" })); + const persistence = new IndexPersistence(failingKv as never, bm25, null); + + let unhandled = false; + const onUnhandled = () => { + unhandled = true; + }; + process.on("unhandledRejection", onUnhandled); + + try { + persistence.scheduleSave(); + vi.advanceTimersByTime(5000); + await vi.runAllTimersAsync(); + // give microtasks a chance to flush + await Promise.resolve(); + expect(failingKv.set).toHaveBeenCalled(); + expect(unhandled).toBe(false); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("save() does not throw when kv.set rejects (#204)", async () => { + const failingKv = { + ...mockKV(), + set: vi.fn(async () => { + throw new Error("TIMEOUT"); + }), + }; + const bm25 = new SearchIndex(); + bm25.add(makeObs({ id: "obs_1", title: "auth handler" })); + const persistence = new IndexPersistence(failingKv as never, bm25, null); + + await expect(persistence.save()).resolves.toBeUndefined(); + }); + + // #797: first run after upgrading to 0.9.25 crashed with + // 'TypeError: Cannot read properties of undefined (reading "v")' + // because some iii-state adapters return `undefined` (not `null`) + // for a missing key. The load path's `value !== null` check passed + // undefined to loadManifestData, which then read `undefined.v`. + it("load() returns null instead of crashing when kv.get returns undefined for the manifest (#797)", async () => { + const undefinedKv = { + ...mockKV(), + get: vi.fn(async () => undefined), + }; + const persistence = new IndexPersistence( + undefinedKv as never, + new SearchIndex(), + null, + ); + + const loaded = await persistence.load(); + expect(loaded.bm25).toBeNull(); + expect(loaded.vector).toBeNull(); + }); + + it("load() does not crash when a manifest row value is the wrong shape (#797)", async () => { + const wrongShapeKv = { + ...mockKV(), + get: vi.fn(async () => "not-a-manifest"), + }; + const persistence = new IndexPersistence( + wrongShapeKv as never, + new SearchIndex(), + null, + ); + + await expect(persistence.load()).resolves.toBeDefined(); + }); +}); diff --git a/test/infer-memory-projects.test.ts b/test/infer-memory-projects.test.ts new file mode 100644 index 0000000..ddd5e49 --- /dev/null +++ b/test/infer-memory-projects.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { inferMemoryProjects } from "../src/functions/migrate.js"; +import { KV } from "../src/state/schema.js"; +import type { Memory, Session } from "../src/types.js"; + +function makeMockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function makeMemory(id: string, sessionIds: string[], project?: string): Memory { + return { + id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "bug", + title: `Memory ${id}`, + content: `Content for ${id}`, + concepts: [], + files: [], + sessionIds, + strength: 5, + version: 1, + isLatest: true, + ...(project !== undefined && { project }), + }; +} + +function makeSession(id: string, project: string): Session { + return { + id, + project, + cwd: `/srv/${project}`, + startedAt: new Date().toISOString(), + status: "completed", + observationCount: 0, + }; +} + +describe("inferMemoryProjects", () => { + it("skips memories that already have a project", async () => { + const kv = makeMockKV(); + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", [], "api")); + + const result = await inferMemoryProjects(kv); + + expect(result.skipped).toBe(1); + expect(result.updated).toBe(0); + expect(result.ambiguous).toBe(0); + + const stored = await kv.get(KV.memories, "mem_a"); + expect(stored?.project).toBe("api"); + }); + + it("marks a memory ambiguous when it has no sessionIds", async () => { + const kv = makeMockKV(); + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", [])); + + const result = await inferMemoryProjects(kv); + + expect(result.ambiguous).toBe(1); + expect(result.updated).toBe(0); + + const stored = await kv.get(KV.memories, "mem_a"); + expect(stored?.project).toBeUndefined(); + }); + + it("marks a memory ambiguous when none of its sessions have a project", async () => { + const kv = makeMockKV(); + const session: Session = { + id: "sess_a", + project: "", + cwd: "/tmp", + startedAt: new Date().toISOString(), + status: "completed", + observationCount: 0, + }; + await kv.set(KV.sessions, "sess_a", session); + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_a"])); + + const result = await inferMemoryProjects(kv); + + expect(result.ambiguous).toBe(1); + expect(result.updated).toBe(0); + }); + + it("marks a memory ambiguous when all its sessions are missing from KV", async () => { + const kv = makeMockKV(); + // Memory references sessions that don't exist (e.g. deleted) + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["ghost_sess_1", "ghost_sess_2"])); + + const result = await inferMemoryProjects(kv); + + expect(result.ambiguous).toBe(1); + expect(result.updated).toBe(0); + }); + + it("infers project when all sessions belong to the same project", async () => { + const kv = makeMockKV(); + await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api")); + await kv.set(KV.sessions, "sess_2", makeSession("sess_2", "api")); + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1", "sess_2"])); + + const result = await inferMemoryProjects(kv); + + expect(result.updated).toBe(1); + expect(result.skipped).toBe(0); + expect(result.ambiguous).toBe(0); + + const stored = await kv.get(KV.memories, "mem_a"); + expect(stored?.project).toBe("api"); + }); + + it("infers the majority project when sessions span multiple projects", async () => { + const kv = makeMockKV(); + await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api")); + await kv.set(KV.sessions, "sess_2", makeSession("sess_2", "api")); + await kv.set(KV.sessions, "sess_3", makeSession("sess_3", "web")); + // api appears 2 times, web 1 time — api wins strict majority + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1", "sess_2", "sess_3"])); + + const result = await inferMemoryProjects(kv); + + expect(result.updated).toBe(1); + const stored = await kv.get(KV.memories, "mem_a"); + expect(stored?.project).toBe("api"); + }); + + it("marks a memory ambiguous when sessions tie across two projects", async () => { + const kv = makeMockKV(); + await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api")); + await kv.set(KV.sessions, "sess_2", makeSession("sess_2", "web")); + // exact 1-1 tie — no strict majority + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1", "sess_2"])); + + const result = await inferMemoryProjects(kv); + + expect(result.ambiguous).toBe(1); + expect(result.updated).toBe(0); + + const stored = await kv.get(KV.memories, "mem_a"); + expect(stored?.project).toBeUndefined(); + }); + + it("counts correctly but does not write to KV in dry-run mode", async () => { + const kv = makeMockKV(); + await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api")); + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1"])); + + const result = await inferMemoryProjects(kv, true); + + expect(result.updated).toBe(1); + + // KV must not have been written + const stored = await kv.get(KV.memories, "mem_a"); + expect(stored?.project).toBeUndefined(); + }); + + it("handles a mix of already-scoped, updatable, and ambiguous memories in one pass", async () => { + const kv = makeMockKV(); + await kv.set(KV.sessions, "sess_api", makeSession("sess_api", "api")); + await kv.set(KV.sessions, "sess_web", makeSession("sess_web", "web")); + + // Already scoped + await kv.set(KV.memories, "mem_scoped", makeMemory("mem_scoped", [], "existing")); + // Will be updated + await kv.set(KV.memories, "mem_update", makeMemory("mem_update", ["sess_api"])); + // No sessionIds — ambiguous + await kv.set(KV.memories, "mem_no_sess", makeMemory("mem_no_sess", [])); + // Tie — ambiguous + await kv.set(KV.memories, "mem_tie", makeMemory("mem_tie", ["sess_api", "sess_web"])); + + const result = await inferMemoryProjects(kv); + + expect(result.skipped).toBe(1); + expect(result.updated).toBe(1); + expect(result.ambiguous).toBe(2); + + const updated = await kv.get(KV.memories, "mem_update"); + expect(updated?.project).toBe("api"); + + const scoped = await kv.get(KV.memories, "mem_scoped"); + expect(scoped?.project).toBe("existing"); + + const noSess = await kv.get(KV.memories, "mem_no_sess"); + expect(noSess?.project).toBeUndefined(); + + const tie = await kv.get(KV.memories, "mem_tie"); + expect(tie?.project).toBeUndefined(); + }); + + it("ignores missing sessions when voting but still infers if remainder has majority", async () => { + const kv = makeMockKV(); + await kv.set(KV.sessions, "sess_real", makeSession("sess_real", "api")); + // ghost_sess does not exist in KV — should be silently skipped in voting + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_real", "ghost_sess"])); + + const result = await inferMemoryProjects(kv); + + // Only one vote collected (api), which is a strict majority of 1 project out of 1 + expect(result.updated).toBe(1); + const stored = await kv.get(KV.memories, "mem_a"); + expect(stored?.project).toBe("api"); + }); + + it("is idempotent when run twice in succession", async () => { + const kv = makeMockKV(); + await kv.set(KV.sessions, "sess_1", makeSession("sess_1", "api")); + await kv.set(KV.memories, "mem_a", makeMemory("mem_a", ["sess_1"])); + + const first = await inferMemoryProjects(kv); + expect(first.updated).toBe(1); + + const second = await inferMemoryProjects(kv); + expect(second.updated).toBe(0); + expect(second.skipped).toBe(1); + + const stored = await kv.get(KV.memories, "mem_a"); + expect(stored?.project).toBe("api"); + }); +}); diff --git a/test/integration-plaintext-http.test.ts b/test/integration-plaintext-http.test.ts new file mode 100644 index 0000000..70ef689 --- /dev/null +++ b/test/integration-plaintext-http.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import openclawPlugin from "../integrations/openclaw/plugin.mjs"; +import { createPlaintextBearerAuthGuard } from "../integrations/pi/security.ts"; + +type OpenClawHandler = (event: Record) => Promise; + +function mockFetch(): ReturnType { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +function registerOpenClaw(baseUrl: string) { + const handlers = new Map(); + const warn = vi.fn(); + openclawPlugin.register({ + pluginConfig: { base_url: baseUrl }, + logger: { warn }, + on(event: string, handler: OpenClawHandler) { + handlers.set(event, handler); + }, + }); + return { handlers, warn }; +} + +describe("OpenClaw plaintext bearer guard", () => { + const originalFetch = globalThis.fetch; + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv, AGENTMEMORY_SECRET: "secret" }; + delete process.env["AGENTMEMORY_REQUIRE_HTTPS"]; + mockFetch(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + process.env = { ...originalEnv }; + }); + + it("keeps loopback HTTP silent", async () => { + const { handlers, warn } = registerOpenClaw("http://localhost:3111"); + await handlers.get("before_agent_start")?.({ prompt: "recall auth work" }); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns once for non-loopback HTTP with a bearer secret", async () => { + const { handlers, warn } = registerOpenClaw("http://remote.example:3111"); + await handlers.get("before_agent_start")?.({ prompt: "first" }); + await handlers.get("before_agent_start")?.({ prompt: "second" }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://remote.example:3111"); + }); + + it("keeps HTTPS with a bearer secret silent", async () => { + const { handlers, warn } = registerOpenClaw("https://remote.example"); + await handlers.get("before_agent_start")?.({ prompt: "recall auth work" }); + expect(warn).not.toHaveBeenCalled(); + }); + + it("fails before any request when HTTPS is required", () => { + process.env["AGENTMEMORY_REQUIRE_HTTPS"] = "1"; + const fetchMock = mockFetch(); + expect(() => registerOpenClaw("http://remote.example:3111")).toThrow( + /plaintext HTTP to http:\/\/remote\.example:3111/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("pi plaintext bearer guard", () => { + it("keeps loopback HTTP silent", () => { + const warn = vi.fn(); + const guard = createPlaintextBearerAuthGuard(warn, {}); + guard("http://127.0.0.1:3111", "secret"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns once for non-loopback HTTP with a bearer secret", () => { + const warn = vi.fn(); + const guard = createPlaintextBearerAuthGuard(warn, {}); + guard("http://remote.example:3111", "secret"); + guard("http://remote.example:3111", "secret"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://remote.example:3111"); + }); + + it("keeps HTTPS with a bearer secret silent", () => { + const warn = vi.fn(); + const guard = createPlaintextBearerAuthGuard(warn, {}); + guard("https://remote.example", "secret"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("fails before callers can issue a request when HTTPS is required", () => { + const warn = vi.fn(); + const guard = createPlaintextBearerAuthGuard(warn, { + AGENTMEMORY_REQUIRE_HTTPS: "1", + }); + expect(() => guard("http://remote.example:3111", "secret")).toThrow( + /plaintext HTTP to http:\/\/remote\.example:3111/, + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it("treats IPv6 loopback ([::1]) as loopback (URL parser strips brackets)", () => { + const warn = vi.fn(); + const guard = createPlaintextBearerAuthGuard(warn, {}); + guard("http://[::1]:3111", "secret"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns for private LAN IPs — RFC1918 ranges are NOT loopback", () => { + const warn = vi.fn(); + const guard = createPlaintextBearerAuthGuard(warn, {}); + guard("http://192.168.1.50:3111", "secret"); + guard("http://10.0.0.42:3111", "secret"); + expect(warn).toHaveBeenCalledTimes(1); // warn-once + expect(warn.mock.calls[0][0]).toContain("plaintext HTTP to http://192.168.1.50:3111"); + }); + + it("does not warn when no secret is set — guard only fires when a bearer would actually be sent", () => { + const warn = vi.fn(); + const guard = createPlaintextBearerAuthGuard(warn, {}); + guard("http://remote.example:3111", ""); + guard("http://remote.example:3111", undefined); + expect(warn).not.toHaveBeenCalled(); + }); + + it("treats hostnames that LOOK loopback but aren't (localhost.evil.com) as remote", () => { + const warn = vi.fn(); + const guard = createPlaintextBearerAuthGuard(warn, {}); + guard("http://localhost.evil.com:3111", "secret"); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); + +describe("Hermes plaintext bearer guard", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "agentmemory-hermes-test-")); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it("covers loopback, remote HTTP, HTTPS, and require-HTTPS behavior", () => { + const script = String.raw` +import importlib.util +import os +import sys + +spec = importlib.util.spec_from_file_location("agentmemory_hermes", "integrations/hermes/__init__.py") +mod = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(mod) + +for key in ("AGENTMEMORY_SECRET", "AGENTMEMORY_URL", "AGENTMEMORY_REQUIRE_HTTPS"): + os.environ.pop(key, None) + +warnings = [] +mod._reset_plaintext_bearer_guard_for_tests() +mod._check_plaintext_bearer_guard("http://localhost:3111", "secret", warnings.append) +assert warnings == [], warnings + +mod._reset_plaintext_bearer_guard_for_tests() +mod._check_plaintext_bearer_guard("http://remote.example:3111", "secret", warnings.append) +mod._check_plaintext_bearer_guard("http://remote.example:3111", "secret", warnings.append) +assert len(warnings) == 1, warnings +assert "plaintext HTTP to http://remote.example:3111" in warnings[0], warnings + +warnings = [] +mod._reset_plaintext_bearer_guard_for_tests() +mod._check_plaintext_bearer_guard("https://remote.example", "secret", warnings.append) +assert warnings == [], warnings + +calls = [] +def fake_urlopen(req, timeout=0): + calls.append(req) + raise AssertionError("request should not be sent") + +mod.urlopen = fake_urlopen +os.environ["AGENTMEMORY_REQUIRE_HTTPS"] = "1" +try: + mod._api("http://remote.example:3111", "health", method="GET", secret="secret") +except RuntimeError as exc: + assert "plaintext HTTP to http://remote.example:3111" in str(exc), exc +else: + raise AssertionError("expected RuntimeError") +assert calls == [], calls +`; + const result = spawnSync("python3", ["-c", script], { + cwd: process.cwd(), + env: { ...process.env, HOME: home }, + encoding: "utf8", + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + }); +}); diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..6bb96dd --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; + +const BASE_URL = process.env["AGENTMEMORY_URL"] || "http://localhost:3111"; +const SECRET = process.env["AGENTMEMORY_SECRET"] || ""; + +const SESSION_ID = `test_${Date.now()}`; +const PROJECT = "/tmp/test-project"; + +function url(path: string): string { + return `${BASE_URL}${path}`; +} + +function authHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + if (SECRET) { + headers["Authorization"] = `Bearer ${SECRET}`; + } + return headers; +} + +async function json(res: Response): Promise { + const text = await res.text(); + try { + return JSON.parse(text); + } catch { + return text; + } +} + +describe("agentmemory integration", () => { + beforeAll(async () => { + const res = await fetch(url("/agentmemory/health")).catch(() => null); + if (!res || !res.ok) { + throw new Error( + `agentmemory is not running at ${BASE_URL}. Start it with: docker compose up -d && npm start`, + ); + } + }); + + describe("health", () => { + it("returns ok", async () => { + const res = await fetch(url("/agentmemory/health")); + expect(res.status).toBe(200); + const body = (await json(res)) as { status: string; service: string }; + expect(["ok", "healthy"]).toContain(body.status); + expect(body.service).toBe("agentmemory"); + }); + }); + + describe("session lifecycle", () => { + it("starts a session", async () => { + const res = await fetch(url("/agentmemory/session/start"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sessionId: SESSION_ID, + project: PROJECT, + cwd: PROJECT, + }), + }); + expect(res.status).toBe(200); + const body = (await json(res)) as { + session: { id: string; status: string }; + context: string; + }; + expect(body.session.id).toBe(SESSION_ID); + expect(body.session.status).toBe("active"); + expect(typeof body.context).toBe("string"); + }); + + it("lists sessions including the new one", async () => { + const res = await fetch(url("/agentmemory/sessions")); + expect(res.status).toBe(200); + const body = (await json(res)) as { + sessions: Array<{ id: string }>; + }; + expect(Array.isArray(body.sessions)).toBe(true); + const found = body.sessions.find((s) => s.id === SESSION_ID); + expect(found).toBeDefined(); + }); + + it("ends the session", async () => { + const res = await fetch(url("/agentmemory/session/end"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId: SESSION_ID }), + }); + expect(res.status).toBe(200); + const body = (await json(res)) as { success: boolean }; + expect(body.success).toBe(true); + }); + + it("session is marked completed", async () => { + const res = await fetch(url("/agentmemory/sessions")); + const body = (await json(res)) as { + sessions: Array<{ id: string; status: string; endedAt?: string }>; + }; + const session = body.sessions.find((s) => s.id === SESSION_ID); + expect(session).toBeDefined(); + expect(session!.status).toBe("completed"); + expect(session!.endedAt).toBeDefined(); + }); + }); + + describe("observations", () => { + const OBS_SESSION = `test_obs_${Date.now()}`; + + beforeAll(async () => { + await fetch(url("/agentmemory/session/start"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sessionId: OBS_SESSION, + project: PROJECT, + cwd: PROJECT, + }), + }); + }); + + afterAll(async () => { + await fetch(url("/agentmemory/session/end"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ sessionId: OBS_SESSION }), + }); + }); + + it("captures an observation", async () => { + const res = await fetch(url("/agentmemory/observe"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "post_tool_use", + sessionId: OBS_SESSION, + project: PROJECT, + cwd: PROJECT, + timestamp: new Date().toISOString(), + data: { + tool: "Edit", + file: "src/auth.ts", + content: "Added JWT token validation middleware", + }, + }), + }); + expect(res.status).toBe(201); + }); + + it("captures a second observation", async () => { + const res = await fetch(url("/agentmemory/observe"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + hookType: "post_tool_use", + sessionId: OBS_SESSION, + project: PROJECT, + cwd: PROJECT, + timestamp: new Date().toISOString(), + data: { + tool: "Bash", + command: "npm test", + output: "Tests: 12 passed, 0 failed", + }, + }), + }); + expect(res.status).toBe(201); + }); + + it("lists observations for the session", async () => { + const res = await fetch( + url(`/agentmemory/observations?sessionId=${OBS_SESSION}`), + ); + expect(res.status).toBe(200); + const body = (await json(res)) as { + observations: Array<{ id: string; sessionId: string }>; + }; + expect(Array.isArray(body.observations)).toBe(true); + }); + + it("returns 400 without sessionId", async () => { + const res = await fetch(url("/agentmemory/observations")); + expect(res.status).toBe(400); + const body = (await json(res)) as { error: string }; + expect(body.error).toBe("sessionId required"); + }); + }); + + describe("search", () => { + it("searches observations", async () => { + const res = await fetch(url("/agentmemory/search"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ query: "auth", limit: 5 }), + }); + expect(res.status).toBe(200); + const body = await json(res); + expect(body).toBeDefined(); + }); + + it("returns results for empty limit", async () => { + const res = await fetch(url("/agentmemory/search"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ query: "test" }), + }); + expect(res.status).toBe(200); + }); + }); + + describe("context", () => { + it("generates context for a project", async () => { + const res = await fetch(url("/agentmemory/context"), { + method: "POST", + headers: authHeaders(), + body: JSON.stringify({ + sessionId: "ctx-test", + project: PROJECT, + }), + }); + expect(res.status).toBe(200); + const body = (await json(res)) as { context: string }; + expect(typeof body.context).toBe("string"); + }); + }); + + describe("viewer", () => { + it("serves the viewer HTML", async () => { + const res = await fetch(url("/agentmemory/viewer"), { + headers: SECRET ? authHeaders() : undefined, + }); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body).toContain("html"); + }); + }); + + describe("dashboard list endpoints", () => { + it("GET /semantic returns { semantic: [...] }", async () => { + const res = await fetch(url("/agentmemory/semantic"), { + headers: SECRET ? authHeaders() : undefined, + }); + expect(res.status).toBe(200); + const body = (await json(res)) as { semantic: unknown[] }; + expect(Array.isArray(body.semantic)).toBe(true); + }); + + it("GET /procedural returns { procedural: [...] }", async () => { + const res = await fetch(url("/agentmemory/procedural"), { + headers: SECRET ? authHeaders() : undefined, + }); + expect(res.status).toBe(200); + const body = (await json(res)) as { procedural: unknown[] }; + expect(Array.isArray(body.procedural)).toBe(true); + }); + + it("GET /relations returns { relations: [...] }", async () => { + const res = await fetch(url("/agentmemory/relations"), { + headers: SECRET ? authHeaders() : undefined, + }); + expect(res.status).toBe(200); + const body = (await json(res)) as { relations: unknown[] }; + expect(Array.isArray(body.relations)).toBe(true); + }); + }); + + describe("auth", () => { + it("health endpoint is always public", async () => { + const res = await fetch(url("/agentmemory/health")); + expect(res.status).toBe(200); + }); + + if (SECRET) { + it("rejects unauthenticated requests", async () => { + const res = await fetch(url("/agentmemory/search"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "test" }), + }); + expect(res.status).toBe(401); + }); + + it("rejects wrong bearer token", async () => { + const res = await fetch(url("/agentmemory/search"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer wrong-token", + }, + body: JSON.stringify({ query: "test" }), + }); + expect(res.status).toBe(401); + }); + + it("rejects unauthenticated viewer requests on the API port", async () => { + const res = await fetch(url("/agentmemory/viewer")); + expect(res.status).toBe(401); + }); + } + }); +}); diff --git a/test/leases.test.ts b/test/leases.test.ts new file mode 100644 index 0000000..95f74b5 --- /dev/null +++ b/test/leases.test.ts @@ -0,0 +1,400 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerLeasesFunction } from "../src/functions/leases.js"; +import type { Action, Lease } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeAction( + id: string, + status: Action["status"] = "pending", +): Action { + return { + id, + title: `Action ${id}`, + description: `Description for ${id}`, + status, + priority: 5, + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + createdBy: "agent-setup", + tags: [], + sourceObservationIds: [], + sourceMemoryIds: [], + }; +} + +describe("Lease Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerLeasesFunction(sdk as never, kv as never); + + await kv.set("mem:actions", "act_1", makeAction("act_1", "pending")); + await kv.set("mem:actions", "act_2", makeAction("act_2", "done")); + await kv.set("mem:actions", "act_3", makeAction("act_3", "cancelled")); + await kv.set("mem:actions", "act_4", makeAction("act_4", "pending")); + }); + + describe("mem::lease-acquire", () => { + it("acquires a lease for a valid action", async () => { + const result = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease; renewed: boolean }; + + expect(result.success).toBe(true); + expect(result.lease.actionId).toBe("act_1"); + expect(result.lease.agentId).toBe("agent-a"); + expect(result.lease.status).toBe("active"); + expect(result.renewed).toBe(false); + expect(result.lease.id).toMatch(/^lse_/); + }); + + it("returns error when actionId or agentId is missing", async () => { + const r1 = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "", + })) as { success: boolean; error: string }; + expect(r1.success).toBe(false); + expect(r1.error).toBe("actionId and agentId are required"); + + const r2 = (await sdk.trigger("mem::lease-acquire", { + actionId: "", + agentId: "agent-a", + })) as { success: boolean; error: string }; + expect(r2.success).toBe(false); + expect(r2.error).toBe("actionId and agentId are required"); + }); + + it("returns error for nonexistent action", async () => { + const result = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_nonexistent", + agentId: "agent-a", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("action not found"); + }); + + it("returns error for done action", async () => { + const result = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_2", + agentId: "agent-a", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("action already completed"); + }); + + it("returns error for cancelled action", async () => { + const result = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_3", + agentId: "agent-a", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("action already completed"); + }); + + it("returns existing lease when same agent already holds it", async () => { + const first = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease }; + + const second = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease; renewed: boolean; message: string }; + + expect(second.success).toBe(true); + expect(second.lease.id).toBe(first.lease.id); + expect(second.renewed).toBe(false); + expect(second.message).toBe("Already holding this lease"); + }); + + it("returns conflict error when different agent holds the lease", async () => { + await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + }); + + const result = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-b", + })) as { success: boolean; error: string; heldBy: string; expiresAt: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("action already leased"); + expect(result.heldBy).toBe("agent-a"); + expect(result.expiresAt).toBeDefined(); + }); + + it("sets action status to active after acquire", async () => { + await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + }); + + const action = await kv.get("mem:actions", "act_1"); + expect(action!.status).toBe("active"); + expect(action!.assignedTo).toBe("agent-a"); + }); + }); + + describe("mem::lease-release", () => { + it("releases an active lease", async () => { + await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + }); + + const result = (await sdk.trigger("mem::lease-release", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; released: boolean }; + + expect(result.success).toBe(true); + expect(result.released).toBe(true); + }); + + it("sets action to done when result is provided", async () => { + await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + }); + + await sdk.trigger("mem::lease-release", { + actionId: "act_1", + agentId: "agent-a", + result: "completed successfully", + }); + + const action = await kv.get("mem:actions", "act_1"); + expect(action!.status).toBe("done"); + expect(action!.result).toBe("completed successfully"); + expect(action!.assignedTo).toBeUndefined(); + }); + + it("sets action to pending when no result is provided", async () => { + await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + }); + + await sdk.trigger("mem::lease-release", { + actionId: "act_1", + agentId: "agent-a", + }); + + const action = await kv.get("mem:actions", "act_1"); + expect(action!.status).toBe("pending"); + expect(action!.assignedTo).toBeUndefined(); + }); + + it("returns error when no active lease exists for agent", async () => { + const result = (await sdk.trigger("mem::lease-release", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("no active lease found for this agent"); + }); + + it("returns error when actionId or agentId is missing", async () => { + const result = (await sdk.trigger("mem::lease-release", { + actionId: "", + agentId: "agent-a", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("actionId and agentId are required"); + }); + }); + + describe("mem::lease-renew", () => { + it("renews an active non-expired lease", async () => { + const acquired = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease }; + + const originalExpiry = acquired.lease.expiresAt; + + const result = (await sdk.trigger("mem::lease-renew", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease }; + + expect(result.success).toBe(true); + expect(result.lease.renewedAt).toBeDefined(); + expect( + new Date(result.lease.expiresAt).getTime(), + ).toBeGreaterThanOrEqual(new Date(originalExpiry).getTime()); + }); + + it("returns error when lease is expired", async () => { + const acquired = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease }; + + acquired.lease.expiresAt = new Date(Date.now() - 60000).toISOString(); + await kv.set("mem:leases", acquired.lease.id, acquired.lease); + + const result = (await sdk.trigger("mem::lease-renew", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("no active (non-expired) lease to renew"); + }); + + it("returns error when actionId or agentId is missing", async () => { + const result = (await sdk.trigger("mem::lease-renew", { + actionId: "", + agentId: "agent-a", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("actionId and agentId are required"); + }); + }); + + describe("mem::lease-cleanup", () => { + it("expires active leases past their expiresAt and resets actions to pending", async () => { + const acquired = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease }; + + acquired.lease.expiresAt = new Date(Date.now() - 60000).toISOString(); + await kv.set("mem:leases", acquired.lease.id, acquired.lease); + + const result = (await sdk.trigger("mem::lease-cleanup", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(1); + + const lease = await kv.get("mem:leases", acquired.lease.id); + expect(lease!.status).toBe("expired"); + + const action = await kv.get("mem:actions", "act_1"); + expect(action!.status).toBe("pending"); + expect(action!.assignedTo).toBeUndefined(); + }); + + it("does not expire non-expired active leases", async () => { + await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + }); + + const result = (await sdk.trigger("mem::lease-cleanup", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(0); + + const action = await kv.get("mem:actions", "act_1"); + expect(action!.status).toBe("active"); + }); + + it("handles multiple expired leases across different actions", async () => { + const a1 = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease }; + + const a4 = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_4", + agentId: "agent-b", + })) as { success: boolean; lease: Lease }; + + a1.lease.expiresAt = new Date(Date.now() - 60000).toISOString(); + await kv.set("mem:leases", a1.lease.id, a1.lease); + + a4.lease.expiresAt = new Date(Date.now() - 30000).toISOString(); + await kv.set("mem:leases", a4.lease.id, a4.lease); + + const result = (await sdk.trigger("mem::lease-cleanup", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(2); + }); + + it("does not reset action when action is no longer active", async () => { + const acquired = (await sdk.trigger("mem::lease-acquire", { + actionId: "act_1", + agentId: "agent-a", + })) as { success: boolean; lease: Lease }; + + acquired.lease.expiresAt = new Date(Date.now() - 60000).toISOString(); + await kv.set("mem:leases", acquired.lease.id, acquired.lease); + + const action = await kv.get("mem:actions", "act_1"); + action!.status = "done"; + await kv.set("mem:actions", "act_1", action); + + await sdk.trigger("mem::lease-cleanup", {}); + + const updatedAction = await kv.get("mem:actions", "act_1"); + expect(updatedAction!.status).toBe("done"); + }); + }); +}); diff --git a/test/lessons.test.ts b/test/lessons.test.ts new file mode 100644 index 0000000..4a55003 --- /dev/null +++ b/test/lessons.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerLessonsFunctions } from "../src/functions/lessons.js"; +import type { Lesson } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Lessons", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerLessonsFunctions(sdk as never, kv as never); + }); + + describe("mem::lesson-save", () => { + it("creates a lesson with default confidence 0.5", async () => { + const result = (await sdk.trigger("mem::lesson-save", { + content: "Always use execFile instead of exec", + context: "Security best practice", + project: "/test", + tags: ["security"], + })) as { success: boolean; action: string; lesson: Lesson }; + + expect(result.success).toBe(true); + expect(result.action).toBe("created"); + expect(result.lesson.confidence).toBe(0.5); + expect(result.lesson.content).toBe("Always use execFile instead of exec"); + expect(result.lesson.source).toBe("manual"); + expect(result.lesson.reinforcements).toBe(0); + }); + + it("accepts custom confidence", async () => { + const result = (await sdk.trigger("mem::lesson-save", { + content: "Test lesson", + confidence: 0.8, + })) as { lesson: Lesson }; + + expect(result.lesson.confidence).toBe(0.8); + }); + + it("clamps invalid confidence to default", async () => { + const result = (await sdk.trigger("mem::lesson-save", { + content: "Bad confidence", + confidence: 5.0, + })) as { lesson: Lesson }; + + expect(result.lesson.confidence).toBe(0.5); + }); + + it("strengthens existing lesson on duplicate content", async () => { + const first = (await sdk.trigger("mem::lesson-save", { + content: "Duplicate lesson", + })) as { action: string; lesson: Lesson }; + + expect(first.action).toBe("created"); + const originalId = first.lesson.id; + + const second = (await sdk.trigger("mem::lesson-save", { + content: "Duplicate lesson", + })) as { action: string; lesson: Lesson }; + + expect(second.action).toBe("strengthened"); + expect(second.lesson.id).toBe(originalId); + expect(second.lesson.reinforcements).toBe(1); + expect(second.lesson.confidence).toBeGreaterThan(0.5); + }); + + it("rejects empty content", async () => { + const result = (await sdk.trigger("mem::lesson-save", { + content: "", + })) as { success: boolean }; + + expect(result.success).toBe(false); + }); + + it("sets crystal source and sourceIds when provided", async () => { + const result = (await sdk.trigger("mem::lesson-save", { + content: "Crystal-derived lesson", + source: "crystal", + sourceIds: ["crys_123"], + confidence: 0.6, + })) as { lesson: Lesson }; + + expect(result.lesson.source).toBe("crystal"); + expect(result.lesson.sourceIds).toEqual(["crys_123"]); + expect(result.lesson.confidence).toBe(0.6); + }); + }); + + describe("mem::lesson-recall", () => { + beforeEach(async () => { + await sdk.trigger("mem::lesson-save", { + content: "Database indexing improves query performance", + project: "/app", + tags: ["database"], + confidence: 0.9, + }); + await sdk.trigger("mem::lesson-save", { + content: "Always validate user input at boundaries", + project: "/app", + tags: ["security"], + confidence: 0.3, + }); + await sdk.trigger("mem::lesson-save", { + content: "Use TypeScript strict mode for type safety", + project: "/other", + tags: ["typescript"], + }); + }); + + it("finds lessons matching query", async () => { + const result = (await sdk.trigger("mem::lesson-recall", { + query: "database performance", + })) as { success: boolean; lessons: Array }; + + expect(result.success).toBe(true); + expect(result.lessons.length).toBeGreaterThan(0); + expect(result.lessons[0].content).toContain("Database indexing"); + }); + + it("filters by project", async () => { + const result = (await sdk.trigger("mem::lesson-recall", { + query: "type safety typescript", + project: "/other", + })) as { lessons: Lesson[] }; + + expect(result.lessons.length).toBe(1); + expect(result.lessons[0].project).toBe("/other"); + }); + + it("filters by minConfidence", async () => { + const result = (await sdk.trigger("mem::lesson-recall", { + query: "validate input", + minConfidence: 0.5, + })) as { lessons: Lesson[] }; + + expect(result.lessons.length).toBe(0); + }); + + it("returns empty for no matches", async () => { + const result = (await sdk.trigger("mem::lesson-recall", { + query: "xyznonexistent", + })) as { lessons: Lesson[] }; + + expect(result.lessons.length).toBe(0); + }); + + it("rejects empty query", async () => { + const result = (await sdk.trigger("mem::lesson-recall", { + query: "", + })) as { success: boolean }; + + expect(result.success).toBe(false); + }); + }); + + describe("mem::lesson-list", () => { + beforeEach(async () => { + await sdk.trigger("mem::lesson-save", { content: "Lesson A", confidence: 0.9, project: "/app" }); + await sdk.trigger("mem::lesson-save", { content: "Lesson B", confidence: 0.3, project: "/app" }); + await sdk.trigger("mem::lesson-save", { content: "Lesson C", confidence: 0.7, source: "crystal" }); + }); + + it("lists all lessons sorted by confidence", async () => { + const result = (await sdk.trigger("mem::lesson-list", {})) as { lessons: Lesson[] }; + + expect(result.lessons.length).toBe(3); + expect(result.lessons[0].confidence).toBe(0.9); + expect(result.lessons[2].confidence).toBe(0.3); + }); + + it("filters by project", async () => { + const result = (await sdk.trigger("mem::lesson-list", { project: "/app" })) as { lessons: Lesson[] }; + expect(result.lessons.length).toBe(2); + }); + + it("filters by source", async () => { + const result = (await sdk.trigger("mem::lesson-list", { source: "crystal" })) as { lessons: Lesson[] }; + expect(result.lessons.length).toBe(1); + }); + + it("filters by minConfidence", async () => { + const result = (await sdk.trigger("mem::lesson-list", { minConfidence: 0.5 })) as { lessons: Lesson[] }; + expect(result.lessons.length).toBe(2); + }); + + it("respects limit", async () => { + const result = (await sdk.trigger("mem::lesson-list", { limit: 1 })) as { lessons: Lesson[] }; + expect(result.lessons.length).toBe(1); + }); + }); + + describe("mem::lesson-strengthen", () => { + it("increases confidence with diminishing returns", async () => { + const saved = (await sdk.trigger("mem::lesson-save", { + content: "Strengthen me", + confidence: 0.5, + })) as { lesson: Lesson }; + + const result = (await sdk.trigger("mem::lesson-strengthen", { + lessonId: saved.lesson.id, + })) as { success: boolean; lesson: Lesson }; + + expect(result.success).toBe(true); + expect(result.lesson.reinforcements).toBe(1); + expect(result.lesson.confidence).toBeCloseTo(0.55, 2); + expect(result.lesson.lastReinforcedAt).toBeDefined(); + }); + + it("caps confidence at 1.0", async () => { + const saved = (await sdk.trigger("mem::lesson-save", { + content: "High confidence", + confidence: 0.95, + })) as { lesson: Lesson }; + + const result = (await sdk.trigger("mem::lesson-strengthen", { + lessonId: saved.lesson.id, + })) as { lesson: Lesson }; + + expect(result.lesson.confidence).toBeLessThanOrEqual(1.0); + }); + + it("fails for missing lessonId", async () => { + const result = (await sdk.trigger("mem::lesson-strengthen", { + lessonId: "nonexistent", + })) as { success: boolean }; + + expect(result.success).toBe(false); + }); + }); + + describe("mem::lesson-decay-sweep", () => { + it("decays old lessons incrementally", async () => { + const saved = (await sdk.trigger("mem::lesson-save", { + content: "Old lesson", + confidence: 0.8, + })) as { lesson: Lesson }; + + const lessons = await kv.list("mem:lessons"); + const lesson = lessons[0]; + lesson.createdAt = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(); + await kv.set("mem:lessons", lesson.id, lesson); + + const result = (await sdk.trigger("mem::lesson-decay-sweep", {})) as { + decayed: number; + softDeleted: number; + }; + + expect(result.decayed).toBe(1); + + const after = await kv.get("mem:lessons", lesson.id); + expect(after!.confidence).toBeLessThan(0.8); + expect(after!.lastDecayedAt).toBeDefined(); + }); + + it("does not decay lessons less than 1 week old", async () => { + await sdk.trigger("mem::lesson-save", { + content: "Recent lesson", + confidence: 0.5, + }); + + const result = (await sdk.trigger("mem::lesson-decay-sweep", {})) as { + decayed: number; + }; + + expect(result.decayed).toBe(0); + }); + + it("soft-deletes low-confidence unreinforced lessons", async () => { + const saved = (await sdk.trigger("mem::lesson-save", { + content: "Weak lesson", + confidence: 0.12, + })) as { lesson: Lesson }; + + const lesson = await kv.get("mem:lessons", saved.lesson.id); + lesson!.createdAt = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(); + await kv.set("mem:lessons", lesson!.id, lesson!); + + const result = (await sdk.trigger("mem::lesson-decay-sweep", {})) as { + softDeleted: number; + }; + + expect(result.softDeleted).toBe(1); + + const after = await kv.get("mem:lessons", saved.lesson.id); + expect(after!.deleted).toBe(true); + }); + + it("uses lastDecayedAt for incremental delta (not full age)", async () => { + const saved = (await sdk.trigger("mem::lesson-save", { + content: "Incremental decay", + confidence: 0.8, + })) as { lesson: Lesson }; + + const lesson = await kv.get("mem:lessons", saved.lesson.id); + lesson!.createdAt = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(); + lesson!.lastDecayedAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + lesson!.confidence = 0.6; + await kv.set("mem:lessons", lesson!.id, lesson!); + + await sdk.trigger("mem::lesson-decay-sweep", {}); + + const after = await kv.get("mem:lessons", saved.lesson.id); + expect(after!.confidence).toBeCloseTo(0.55, 2); + expect(after!.confidence).toBeGreaterThan(0.4); + }); + }); +}); diff --git a/test/mcp-env-placeholder.test.ts b/test/mcp-env-placeholder.test.ts new file mode 100644 index 0000000..2481cf9 --- /dev/null +++ b/test/mcp-env-placeholder.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { resolveEnvOrEmpty } from "../src/mcp/rest-proxy.js"; + +const VAR = "AGENTMEMORY_TEST_URL"; + +describe("resolveEnvOrEmpty — guards against literal ${VAR} placeholders", () => { + let original: string | undefined; + + beforeEach(() => { + original = process.env[VAR]; + delete process.env[VAR]; + }); + + afterEach(() => { + if (original === undefined) delete process.env[VAR]; + else process.env[VAR] = original; + }); + + it("returns '' when env var is unset", () => { + expect(resolveEnvOrEmpty(VAR)).toBe(""); + }); + + it("returns '' when env var is empty string", () => { + process.env[VAR] = ""; + expect(resolveEnvOrEmpty(VAR)).toBe(""); + }); + + it("returns '' when env var is literal ${VAR} placeholder", () => { + process.env[VAR] = "${AGENTMEMORY_TEST_URL}"; + expect(resolveEnvOrEmpty(VAR)).toBe(""); + }); + + it("returns '' when env var is a different literal placeholder", () => { + process.env[VAR] = "${SOME_OTHER_VAR}"; + expect(resolveEnvOrEmpty(VAR)).toBe(""); + }); + + it("preserves a real URL value", () => { + process.env[VAR] = "https://memory.prod.example/api"; + expect(resolveEnvOrEmpty(VAR)).toBe("https://memory.prod.example/api"); + }); + + it("preserves a real secret value that happens to contain a $ char", () => { + process.env[VAR] = "secret-with-$dollar"; + expect(resolveEnvOrEmpty(VAR)).toBe("secret-with-$dollar"); + }); + + it("does not treat ${ at start without matching } as placeholder", () => { + process.env[VAR] = "${unclosed"; + expect(resolveEnvOrEmpty(VAR)).toBe("${unclosed"); + }); + + it("does not treat $VAR (no braces) as placeholder", () => { + process.env[VAR] = "$AGENTMEMORY_URL"; + expect(resolveEnvOrEmpty(VAR)).toBe("$AGENTMEMORY_URL"); + }); +}); diff --git a/test/mcp-prompts.test.ts b/test/mcp-prompts.test.ts new file mode 100644 index 0000000..67ebe71 --- /dev/null +++ b/test/mcp-prompts.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerMcpEndpoints } from "../src/mcp/server.js"; +import type { Session, SessionSummary, Memory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + if (triggerOverrides.has(id)) { + return triggerOverrides.get(id)!(payload); + } + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeReq(body?: unknown, headers?: Record) { + return { + body, + headers: headers || {}, + query_params: {}, + }; +} + +describe("MCP Prompts", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerMcpEndpoints(sdk as never, kv as never); + }); + + it("lists 3 prompts", async () => { + const fn = sdk.getFunction("mcp::prompts::list")!; + const result = (await fn(makeReq())) as { + status_code: number; + body: { prompts: unknown[] }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.prompts).toHaveLength(3); + }); + + it("recall_context returns messages with search results", async () => { + sdk.overrideTrigger("mem::search", async () => ({ + results: [{ observation: { title: "Found something" } }], + })); + + const mem: Memory = { + id: "mem_1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "pattern", + title: "Auth pattern", + content: "Always use JWT", + concepts: ["auth"], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: true, + }; + await kv.set("mem:memories", "mem_1", mem); + + const fn = sdk.getFunction("mcp::prompts::get")!; + const result = (await fn( + makeReq({ + name: "recall_context", + arguments: { task_description: "implement auth" }, + }), + )) as { + status_code: number; + body: { messages: Array<{ role: string; content: { text: string } }> }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.messages).toHaveLength(1); + expect(result.body.messages[0].role).toBe("user"); + expect(result.body.messages[0].content.text).toContain("implement auth"); + }); + + it("session_handoff returns session data", async () => { + const session: Session = { + id: "ses_1", + project: "/test", + cwd: "/test", + startedAt: "2026-02-01T00:00:00Z", + status: "completed", + observationCount: 10, + }; + await kv.set("mem:sessions", "ses_1", session); + + const summary: SessionSummary = { + sessionId: "ses_1", + project: "/test", + createdAt: "2026-02-01T00:00:00Z", + title: "Auth implementation", + narrative: "Implemented JWT auth", + keyDecisions: ["Used JWT"], + filesModified: ["src/auth.ts"], + concepts: ["auth"], + observationCount: 10, + }; + await kv.set("mem:summaries", "ses_1", summary); + + const fn = sdk.getFunction("mcp::prompts::get")!; + const result = (await fn( + makeReq({ + name: "session_handoff", + arguments: { session_id: "ses_1" }, + }), + )) as { + status_code: number; + body: { messages: Array<{ role: string; content: { text: string } }> }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.messages[0].content.text).toContain("Session Handoff"); + expect(result.body.messages[0].content.text).toContain("ses_1"); + }); + + it("detect_patterns returns analysis", async () => { + sdk.overrideTrigger("mem::patterns", async () => ({ + fileCoOccurrence: [{ files: ["a.ts", "b.ts"], count: 5 }], + })); + + const fn = sdk.getFunction("mcp::prompts::get")!; + const result = (await fn( + makeReq({ + name: "detect_patterns", + arguments: { project: "/myapp" }, + }), + )) as { + status_code: number; + body: { messages: Array<{ role: string; content: { text: string } }> }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.messages[0].content.text).toContain("Pattern Analysis"); + }); + + it("returns 400 for missing required arg", async () => { + const fn = sdk.getFunction("mcp::prompts::get")!; + const result = (await fn( + makeReq({ + name: "recall_context", + arguments: {}, + }), + )) as { status_code: number }; + + expect(result.status_code).toBe(400); + }); + + it("returns 400 for unknown prompt name", async () => { + const fn = sdk.getFunction("mcp::prompts::get")!; + const result = (await fn( + makeReq({ + name: "nonexistent_prompt", + arguments: {}, + }), + )) as { status_code: number }; + + expect(result.status_code).toBe(400); + }); + + it("returns 400 for non-string argument value", async () => { + const fn = sdk.getFunction("mcp::prompts::get")!; + const result = (await fn( + makeReq({ + name: "recall_context", + arguments: { task_description: 42 }, + }), + )) as { status_code: number }; + + expect(result.status_code).toBe(400); + }); +}); diff --git a/test/mcp-resources.test.ts b/test/mcp-resources.test.ts new file mode 100644 index 0000000..87922e3 --- /dev/null +++ b/test/mcp-resources.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerMcpEndpoints } from "../src/mcp/server.js"; +import type { Session, SessionSummary, Memory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + if (triggerOverrides.has(id)) { + return triggerOverrides.get(id)!(payload); + } + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeReq(body?: unknown, headers?: Record) { + return { + body, + headers: headers || {}, + query_params: {}, + }; +} + +describe("MCP Resources", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerMcpEndpoints(sdk as never, kv as never); + }); + + it("lists 6 resources", async () => { + const fn = sdk.getFunction("mcp::resources::list")!; + const result = (await fn(makeReq())) as { + status_code: number; + body: { resources: unknown[] }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.resources).toHaveLength(6); + }); + + it("reads agentmemory://status", async () => { + const session: Session = { + id: "ses_1", + project: "/test", + cwd: "/test", + startedAt: new Date().toISOString(), + status: "active", + observationCount: 5, + }; + await kv.set("mem:sessions", "ses_1", session); + + const fn = sdk.getFunction("mcp::resources::read")!; + const result = (await fn(makeReq({ uri: "agentmemory://status" }))) as { + status_code: number; + body: { contents: Array<{ text: string }> }; + }; + + expect(result.status_code).toBe(200); + const data = JSON.parse(result.body.contents[0].text); + expect(data.sessionCount).toBe(1); + }); + + it("reads agentmemory://project/{name}/profile", async () => { + sdk.overrideTrigger("mem::profile", async () => ({ + project: "/myapp", + topConcepts: [{ concept: "auth", frequency: 5 }], + })); + + const fn = sdk.getFunction("mcp::resources::read")!; + const result = (await fn( + makeReq({ uri: "agentmemory://project/myapp/profile" }), + )) as { + status_code: number; + body: { contents: Array<{ text: string }> }; + }; + + expect(result.status_code).toBe(200); + const data = JSON.parse(result.body.contents[0].text); + expect(data.project).toBe("/myapp"); + }); + + it("reads agentmemory://project/{name}/recent with sorted summaries", async () => { + const summaries: SessionSummary[] = [ + { + sessionId: "ses_1", + project: "myapp", + createdAt: "2026-01-01T00:00:00Z", + title: "Old session", + narrative: "old", + keyDecisions: [], + filesModified: [], + concepts: [], + observationCount: 1, + }, + { + sessionId: "ses_2", + project: "myapp", + createdAt: "2026-02-01T00:00:00Z", + title: "New session", + narrative: "new", + keyDecisions: [], + filesModified: [], + concepts: [], + observationCount: 2, + }, + { + sessionId: "ses_3", + project: "other", + createdAt: "2026-02-15T00:00:00Z", + title: "Other project", + narrative: "other", + keyDecisions: [], + filesModified: [], + concepts: [], + observationCount: 3, + }, + ]; + for (const s of summaries) { + await kv.set("mem:summaries", s.sessionId, s); + } + + const fn = sdk.getFunction("mcp::resources::read")!; + const result = (await fn( + makeReq({ uri: "agentmemory://project/myapp/recent" }), + )) as { + status_code: number; + body: { contents: Array<{ text: string }> }; + }; + + expect(result.status_code).toBe(200); + const data = JSON.parse(result.body.contents[0].text); + expect(data).toHaveLength(2); + expect(data[0].sessionId).toBe("ses_2"); + }); + + it("reads agentmemory://memories/latest", async () => { + const memories: Memory[] = [ + { + id: "mem_1", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type: "pattern", + title: "Latest pattern", + content: "content", + concepts: [], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: true, + }, + { + id: "mem_2", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-15T00:00:00Z", + type: "bug", + title: "Old bug", + content: "content", + concepts: [], + files: [], + sessionIds: [], + strength: 3, + version: 2, + isLatest: false, + }, + ]; + for (const m of memories) { + await kv.set("mem:memories", m.id, m); + } + + const fn = sdk.getFunction("mcp::resources::read")!; + const result = (await fn( + makeReq({ uri: "agentmemory://memories/latest" }), + )) as { + status_code: number; + body: { contents: Array<{ text: string }> }; + }; + + expect(result.status_code).toBe(200); + const data = JSON.parse(result.body.contents[0].text); + expect(data).toHaveLength(1); + expect(data[0].id).toBe("mem_1"); + expect(data[0].title).toBe("Latest pattern"); + }); + + it("returns 404 for unknown URI", async () => { + const fn = sdk.getFunction("mcp::resources::read")!; + const result = (await fn( + makeReq({ uri: "agentmemory://nonexistent" }), + )) as { status_code: number }; + + expect(result.status_code).toBe(404); + }); + + it("returns 401 when auth fails", async () => { + const authedSdk = mockSdk(); + const authedKv = mockKV(); + registerMcpEndpoints(authedSdk as never, authedKv as never, "test-secret"); + + const fn = authedSdk.getFunction("mcp::resources::list")!; + const result = (await fn(makeReq())) as { status_code: number }; + expect(result.status_code).toBe(401); + + const authedResult = (await fn( + makeReq(undefined, { authorization: "Bearer test-secret" }), + )) as { status_code: number }; + expect(authedResult.status_code).toBe(200); + }); + + it("handles URI with special characters via decodeURIComponent", async () => { + sdk.overrideTrigger("mem::profile", async (data: any) => ({ + project: data.project, + topConcepts: [], + })); + + const fn = sdk.getFunction("mcp::resources::read")!; + const result = (await fn( + makeReq({ + uri: "agentmemory://project/my%20app%2Fsubdir/profile", + }), + )) as { + status_code: number; + body: { contents: Array<{ text: string }> }; + }; + + expect(result.status_code).toBe(200); + const data = JSON.parse(result.body.contents[0].text); + expect(data.project).toBe("my app/subdir"); + }); + + it("returns 400 for malformed percent-encoding in URI", async () => { + const fn = sdk.getFunction("mcp::resources::read")!; + const result = (await fn( + makeReq({ + uri: "agentmemory://project/bad%E0encoding/profile", + }), + )) as { status_code: number; body: { error: string } }; + + expect(result.status_code).toBe(400); + expect(result.body.error).toContain("percent-encoding"); + }); +}); diff --git a/test/mcp-standalone-proxy.test.ts b/test/mcp-standalone-proxy.test.ts new file mode 100644 index 0000000..dc08a02 --- /dev/null +++ b/test/mcp-standalone-proxy.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import { handleToolCall } from "../src/mcp/standalone.js"; +import { resetHandleForTests } from "../src/mcp/rest-proxy.js"; +import { InMemoryKV } from "../src/mcp/in-memory-kv.js"; + +type FetchMock = ReturnType; + +function installFetch(handler: (url: string, init?: RequestInit) => Response): FetchMock { + const fn = vi.fn(async (url: string | URL, init?: RequestInit) => + handler(url.toString(), init), + ); + (globalThis as { fetch: typeof fetch }).fetch = fn as unknown as typeof fetch; + return fn; +} + +const BASE = "http://localhost:3111"; + +describe("@agentmemory/mcp standalone — server proxy (issue #159)", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + resetHandleForTests(); + process.env["AGENTMEMORY_URL"] = BASE; + delete process.env["AGENTMEMORY_SECRET"]; + }); + + afterEach(() => { + resetHandleForTests(); + globalThis.fetch = originalFetch; + delete process.env["AGENTMEMORY_URL"]; + }); + + it("proxies memory_sessions to GET /agentmemory/sessions when server is up", async () => { + const calls: Array<{ url: string; method: string }> = []; + installFetch((url, init) => { + calls.push({ url, method: init?.method || "GET" }); + if (url.endsWith("/agentmemory/livez")) { + return new Response("ok", { status: 200 }); + } + if (url.includes("/agentmemory/sessions")) { + return new Response( + JSON.stringify({ sessions: [{ id: "sess-1", observations: 69 }] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + + const res = await handleToolCall("memory_sessions", { limit: 5 }); + const body = JSON.parse(res.content[0].text); + expect(body.sessions).toHaveLength(1); + expect(body.sessions[0].id).toBe("sess-1"); + expect(calls.find((c) => c.url.includes("/sessions"))).toBeDefined(); + }); + + it("proxies memory_smart_search to POST /agentmemory/smart-search", async () => { + installFetch((url, init) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.endsWith("/agentmemory/smart-search")) { + const body = JSON.parse((init?.body as string) || "{}"); + return new Response( + JSON.stringify({ + mode: "compact", + query: body.query, + results: [{ id: "m1", score: 0.9 }], + }), + { status: 200 }, + ); + } + return new Response("", { status: 404 }); + }); + const res = await handleToolCall("memory_smart_search", { query: "auth bug", limit: 5 }); + const body = JSON.parse(res.content[0].text); + expect(body.query).toBe("auth bug"); + expect(body.results[0].id).toBe("m1"); + }); + + it("proxies memory_recall to POST /agentmemory/search and forwards format/token_budget (#507)", async () => { + const calls: Array<{ url: string; body?: unknown }> = []; + installFetch((url, init) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ url, body }); + if (url.endsWith("/agentmemory/search")) { + return new Response( + JSON.stringify({ + mode: "full", + facts: [{ id: "m1" }], + narrative: "n", + concepts: ["c"], + files: ["f"], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + const res = await handleToolCall("memory_recall", { + query: "auth bug", + limit: 5, + format: "full", + token_budget: 800, + }); + const body = JSON.parse(res.content[0].text); + expect(body.mode).toBe("full"); + expect(body.facts[0].id).toBe("m1"); + const searchCall = calls.find((c) => c.url.endsWith("/agentmemory/search")); + expect(searchCall).toBeDefined(); + expect(searchCall?.body).toEqual({ + query: "auth bug", + limit: 5, + format: "full", + token_budget: 800, + }); + expect(calls.find((c) => c.url.endsWith("/agentmemory/smart-search"))).toBeUndefined(); + }); + + it("memory_recall defaults format to 'full' when omitted (#507)", async () => { + let recallBody: Record | undefined; + installFetch((url, init) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.endsWith("/agentmemory/search")) { + recallBody = init?.body ? JSON.parse(init.body as string) : undefined; + return new Response(JSON.stringify({ mode: "full", facts: [] }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + await handleToolCall("memory_recall", { query: "x" }); + expect(recallBody?.["format"]).toBe("full"); + expect(recallBody).not.toHaveProperty("token_budget"); + }); + + it("proxies memory_governance_delete to the DELETE REST endpoint", async () => { + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + installFetch((url, init) => { + const method = init?.method || "GET"; + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + calls.push({ + url, + method, + body: init?.body ? JSON.parse(init.body as string) : undefined, + }); + if (url.endsWith("/agentmemory/governance/memories") && method === "DELETE") { + return new Response(JSON.stringify({ success: true, deleted: 2 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("method not allowed", { status: 405, statusText: "Method Not Allowed" }); + }); + + const res = await handleToolCall("memory_governance_delete", { + memoryIds: "mem_1, mem_2", + reason: "cleanup stale test data", + }); + + expect(JSON.parse(res.content[0].text)).toEqual({ success: true, deleted: 2 }); + expect(calls).toEqual([ + { + url: `${BASE}/agentmemory/governance/memories`, + method: "DELETE", + body: { + memoryIds: ["mem_1", "mem_2"], + reason: "cleanup stale test data", + }, + }, + ]); + }); + + it("local fallback returns the same shape as proxy for memory_smart_search", async () => { + installFetch(() => { + throw new Error("ECONNREFUSED"); + }); + const localKv = new InMemoryKV(undefined); + await handleToolCall("memory_save", { content: "shape-check entry" }, localKv); + const res = await handleToolCall("memory_smart_search", { query: "shape" }, localKv); + const body = JSON.parse(res.content[0].text); + expect(body).toHaveProperty("mode", "compact"); + expect(Array.isArray(body.results)).toBe(true); + expect(body.results[0].content).toBe("shape-check entry"); + }); + + it("attaches Bearer token on the proxied tool request, not just the probe", async () => { + process.env["AGENTMEMORY_SECRET"] = "s3cret"; + const authByPath = new Map(); + installFetch((url, init) => { + const auth = (init?.headers as Record | undefined)?.[ + "authorization" + ]; + const u = new URL(url); + authByPath.set(u.pathname, auth); + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + return new Response(JSON.stringify({ sessions: [] }), { status: 200 }); + }); + await handleToolCall("memory_sessions", {}); + expect(authByPath.get("/agentmemory/livez")).toBe("Bearer s3cret"); + expect(authByPath.get("/agentmemory/sessions")).toBe("Bearer s3cret"); + }); + + it("falls back to local InMemoryKV when server is unreachable", async () => { + installFetch(() => { + throw new Error("ECONNREFUSED"); + }); + const localKv = new InMemoryKV(undefined); + await handleToolCall("memory_save", { content: "local only" }, localKv); + const recall = await handleToolCall("memory_recall", { query: "local" }, localKv); + const out = JSON.parse(recall.content[0].text); + expect(out.mode).toBe("compact"); + expect(out.results).toHaveLength(1); + expect(out.results[0].content).toBe("local only"); + }); + + it("invalidates the handle on proxy failure, so the next call re-probes", async () => { + let probeCount = 0; + let serverUp = true; + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) { + probeCount++; + return serverUp ? new Response("ok", { status: 200 }) : new Response("", { status: 500 }); + } + return new Response("boom", { status: 500, statusText: "Internal Server Error" }); + }); + const localKv = new InMemoryKV(undefined); + await handleToolCall("memory_save", { content: "first fallback" }, localKv); + expect(probeCount).toBe(1); + serverUp = false; + await handleToolCall("memory_save", { content: "second fallback" }, localKv); + expect(probeCount).toBe(2); + }); + + it("forwards non-essential tools to /agentmemory/mcp/call (#234)", async () => { + const calls: Array<{ url: string; body?: unknown }> = []; + installFetch((url, init) => { + if (url.endsWith("/agentmemory/livez")) { + return new Response("ok", { status: 200 }); + } + if (url.endsWith("/agentmemory/mcp/call")) { + const body = init?.body ? JSON.parse(init.body as string) : null; + calls.push({ url, body }); + return new Response( + JSON.stringify({ + content: [ + { + type: "text", + text: JSON.stringify({ saved: "lesson_xyz" }), + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("not found", { status: 404 }); + }); + + const res = await handleToolCall("memory_lesson_save", { + title: "Always pin lockfiles", + content: "...", + }); + const body = JSON.parse(res.content[0].text); + expect(body.saved).toBe("lesson_xyz"); + expect(calls).toHaveLength(1); + expect(calls[0].body).toEqual({ + name: "memory_lesson_save", + arguments: { title: "Always pin lockfiles", content: "..." }, + }); + }); + + it("rejects non-essential tools when no server is reachable (#234)", async () => { + installFetch(() => { + throw new Error("ECONNREFUSED"); + }); + const localKv = new InMemoryKV(undefined); + await expect( + handleToolCall("memory_lesson_save", { title: "x" }, localKv), + ).rejects.toThrow(/Unknown tool: memory_lesson_save/); + }); + + it("does not retry local after a validation error", async () => { + const fetchFn = installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + return new Response("{}", { status: 200 }); + }); + const localKv = new InMemoryKV(undefined); + await expect( + handleToolCall("memory_save", { content: "" }, localKv), + ).rejects.toThrow("content is required"); + const remembersCalled = fetchFn.mock.calls.some(([url]) => + String(url).endsWith("/agentmemory/remember"), + ); + expect(remembersCalled).toBe(false); + }); + + it("AGENTMEMORY_FORCE_PROXY=1 skips livez probe and trusts the server", async () => { + process.env["AGENTMEMORY_FORCE_PROXY"] = "1"; + const calls: string[] = []; + installFetch((url, init) => { + calls.push(url); + if (url.endsWith("/agentmemory/livez")) { + throw new Error("probe should be skipped"); + } + if (url.endsWith("/agentmemory/remember")) { + return new Response(JSON.stringify({ id: "m-1", action: "created" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }); + try { + await handleToolCall("memory_save", { content: "force-proxy" }); + expect(calls.some((u) => u.endsWith("/agentmemory/livez"))).toBe(false); + expect(calls.some((u) => u.endsWith("/agentmemory/remember"))).toBe(true); + } finally { + delete process.env["AGENTMEMORY_FORCE_PROXY"]; + } + }); + + it("logs probe failure to stderr so sandboxed clients can diagnose silently dropped tools", async () => { + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) { + throw new Error("ECONNREFUSED 127.0.0.1:3111"); + } + return new Response("not found", { status: 404 }); + }); + const writes: string[] = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write; + try { + const localKv = new InMemoryKV(undefined); + await handleToolCall("memory_save", { content: "diag" }, localKv); + } finally { + process.stderr.write = origWrite; + } + const joined = writes.join(""); + expect(joined).toMatch(/livez probe .* failed/); + expect(joined).toMatch(/AGENTMEMORY_FORCE_PROXY/); + }); + + it("local fallback tools/list returns all 7 IMPLEMENTED_TOOLS regardless of AGENTMEMORY_TOOLS env (#234)", async () => { + const { handleToolsList } = await import("../src/mcp/standalone.js"); + installFetch(() => { + throw new Error("ECONNREFUSED"); + }); + delete process.env["AGENTMEMORY_TOOLS"]; + const before = await handleToolsList(); + const beforeTools = before.tools as Array<{ name: string }>; + expect(beforeTools.map((t) => t.name).sort()).toEqual([ + "memory_audit", + "memory_export", + "memory_governance_delete", + "memory_recall", + "memory_save", + "memory_sessions", + "memory_smart_search", + ]); + expect(beforeTools).toHaveLength(7); + + resetHandleForTests(); + process.env["AGENTMEMORY_TOOLS"] = "core"; + const core = await handleToolsList(); + expect((core.tools as unknown[]).length).toBe(7); + delete process.env["AGENTMEMORY_TOOLS"]; + }); + + it("AGENTMEMORY_PROBE_TIMEOUT_MS overrides the default probe timeout", async () => { + process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"] = "50"; + let probeStarted = 0; + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) { + probeStarted++; + return new Response("ok", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + try { + const localKv = new InMemoryKV(undefined); + await handleToolCall("memory_save", { content: "timeout-knob" }, localKv); + expect(probeStarted).toBe(1); + } finally { + delete process.env["AGENTMEMORY_PROBE_TIMEOUT_MS"]; + } + }); +}); diff --git a/test/mcp-standalone.test.ts b/test/mcp-standalone.test.ts new file mode 100644 index 0000000..b48eade --- /dev/null +++ b/test/mcp-standalone.test.ts @@ -0,0 +1,452 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(false), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock("../src/mcp/transport.js", () => ({ + createStdioTransport: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), +})); + +vi.mock("../src/config.js", () => ({ + getStandalonePersistPath: vi.fn(() => "/tmp/test-standalone.json"), +})); + +import { + getAllTools, + CORE_TOOLS, + V040_TOOLS, +} from "../src/mcp/tools-registry.js"; +import { InMemoryKV } from "../src/mcp/in-memory-kv.js"; +import { handleToolCall } from "../src/mcp/standalone.js"; +import { + resetHandleForTests, + setLivezProbe, +} from "../src/mcp/rest-proxy.js"; +import { writeFileSync } from "node:fs"; + +// Issue #449: hard-coded fetch() against :3111 in the livez probe was racing +// with vitest's mock setup, making this file the "10-11 pre-existing failures" +// referenced in the last 5 release notes. Stub the probe with an instant +// ok:false response so the shim takes the deterministic InMemoryKV fallback +// path on every test. Guard the real network with a fetch trap so any +// regression that bypasses the DI seam fails loudly instead of timing out. +const instantLocalFallbackProbe = vi.fn(async () => ({ + ok: false, + status: 0, + statusText: "stubbed: forced local fallback", +})); + +const fetchTrap = vi.fn(async (url: unknown) => { + throw new Error( + `unexpected real fetch() call in mcp-standalone.test.ts: ${String(url)} — the livez probe DI stub should have absorbed this`, + ); +}); + +describe("Tools Registry", () => { + it("getAllTools returns all tools with unique names", () => { + const tools = getAllTools(); + expect(tools.length).toBeGreaterThanOrEqual(41); + const names = new Set(tools.map((t) => t.name)); + expect(names.size).toBe(tools.length); + for (const required of [ + "memory_verify", + "memory_lesson_save", + "memory_lesson_recall", + "memory_obsidian_export", + "memory_save", + "memory_recall", + ]) { + expect(tools.some((t) => t.name === required)).toBe(true); + } + }); + + it("CORE_TOOLS has 14 items", () => { + expect(CORE_TOOLS.length).toBe(14); + }); + + it("V040_TOOLS has 8 items", () => { + expect(V040_TOOLS.length).toBe(8); + }); + + it("all tools have required name, description, inputSchema fields", () => { + const tools = getAllTools(); + for (const tool of tools) { + expect(tool.name).toBeDefined(); + expect(typeof tool.name).toBe("string"); + expect(tool.name.length).toBeGreaterThan(0); + expect(tool.description).toBeDefined(); + expect(typeof tool.description).toBe("string"); + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema.type).toBe("object"); + expect(tool.inputSchema.properties).toBeDefined(); + } + }); +}); + +describe("InMemoryKV", () => { + let kv: InMemoryKV; + + beforeEach(() => { + kv = new InMemoryKV(); + }); + + it("get/set/list/delete operations work", async () => { + await kv.set("scope1", "key1", { value: "hello" }); + const result = await kv.get<{ value: string }>("scope1", "key1"); + expect(result).toEqual({ value: "hello" }); + + const list = await kv.list("scope1"); + expect(list.length).toBe(1); + + await kv.delete("scope1", "key1"); + const afterDelete = await kv.get("scope1", "key1"); + expect(afterDelete).toBeNull(); + }); + + it("list returns empty array for unknown scope", async () => { + const result = await kv.list("nonexistent"); + expect(result).toEqual([]); + }); + + it("persist writes JSON", async () => { + const kvWithPersist = new InMemoryKV("/tmp/test-kv.json"); + await kvWithPersist.set("scope1", "key1", { data: "test" }); + kvWithPersist.persist(); + + expect(writeFileSync).toHaveBeenCalledWith( + "/tmp/test-kv.json", + expect.any(String), + "utf-8", + ); + const written = vi.mocked(writeFileSync).mock.calls[0][1] as string; + const parsed = JSON.parse(written); + expect(parsed.scope1.key1).toEqual({ data: "test" }); + }); + + it("set overwrites existing values", async () => { + await kv.set("scope1", "key1", "first"); + await kv.set("scope1", "key1", "second"); + const result = await kv.get("scope1", "key1"); + expect(result).toBe("second"); + const list = await kv.list("scope1"); + expect(list.length).toBe(1); + }); +}); + +describe("handleToolCall", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.mocked(writeFileSync).mockClear(); + instantLocalFallbackProbe.mockClear(); + fetchTrap.mockClear(); + // Order matters: resetHandleForTests() restores the default probe and + // clears the cached handle. Install the stub AFTER the reset so the + // shim's next resolveHandle() call hits the stubbed instant-fail path + // instead of the real 2s AbortController fetch. + resetHandleForTests(); + setLivezProbe(instantLocalFallbackProbe); + (globalThis as { fetch: typeof fetch }).fetch = fetchTrap as unknown as typeof fetch; + }); + + afterEach(() => { + (globalThis as { fetch: typeof fetch }).fetch = originalFetch; + resetHandleForTests(); + }); + + it("livez probe stub is invoked instead of the real fetch (issue #449)", async () => { + const kv = new InMemoryKV(); + await handleToolCall("memory_save", { content: "regression guard" }, kv); + expect(instantLocalFallbackProbe).toHaveBeenCalledTimes(1); + expect(fetchTrap).not.toHaveBeenCalled(); + }); + + it("memory_save persists to disk immediately after saving", async () => { + const kv = new InMemoryKV("/tmp/test-handle.json"); + const result = await handleToolCall( + "memory_save", + { content: "Test memory content" }, + kv, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.saved).toMatch(/^mem_/); + expect(writeFileSync).toHaveBeenCalledWith( + "/tmp/test-handle.json", + expect.any(String), + "utf-8", + ); + }); + + it("memory_save without persist path does not call writeFileSync", async () => { + const kv = new InMemoryKV(); + await handleToolCall("memory_save", { content: "No persist path" }, kv); + expect(writeFileSync).not.toHaveBeenCalled(); + }); + + it("memory_save throws when content is missing", async () => { + const kv = new InMemoryKV(); + await expect( + handleToolCall("memory_save", {}, kv), + ).rejects.toThrow("content is required"); + }); + + it("memory_save rejects non-string content safely (no runtime TypeError)", async () => { + const kv = new InMemoryKV(); + // These would have crashed on .trim() before the type-guard fix. + for (const bogus of [42, {}, [], null, undefined, true]) { + await expect( + handleToolCall("memory_save", { content: bogus }, kv), + ).rejects.toThrow("content is required"); + } + }); + + it("memory_recall returns matching memories", async () => { + const kv = new InMemoryKV(); + await handleToolCall("memory_save", { content: "TypeScript is great" }, kv); + await handleToolCall("memory_save", { content: "Python is also great" }, kv); + const result = await handleToolCall( + "memory_recall", + { query: "typescript" }, + kv, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.results).toHaveLength(1); + expect(parsed.results[0].content).toBe("TypeScript is great"); + }); + + it("memory_save accepts concepts/files as arrays (plugin skill format, #139)", async () => { + const kv = new InMemoryKV(); + const result = await handleToolCall( + "memory_save", + { + content: "Use HMAC for API auth", + concepts: ["hmac", "api-auth", "security"], + files: ["src/auth.ts", "src/middleware.ts"], + }, + kv, + ); + const saved = JSON.parse(result.content[0].text); + const mem = await kv.get<{ concepts: string[]; files: string[] }>( + "mem:memories", + saved.saved, + ); + expect(mem?.concepts).toEqual(["hmac", "api-auth", "security"]); + expect(mem?.files).toEqual(["src/auth.ts", "src/middleware.ts"]); + }); + + it("memory_save still accepts concepts/files as comma-separated strings (legacy)", async () => { + const kv = new InMemoryKV(); + const result = await handleToolCall( + "memory_save", + { + content: "JWT refresh rotation", + concepts: "jwt, refresh, rotation", + files: "src/auth.ts", + }, + kv, + ); + const saved = JSON.parse(result.content[0].text); + const mem = await kv.get<{ concepts: string[]; files: string[] }>( + "mem:memories", + saved.saved, + ); + expect(mem?.concepts).toEqual(["jwt", "refresh", "rotation"]); + expect(mem?.files).toEqual(["src/auth.ts"]); + }); + + it("memory_smart_search falls back to substring match in the standalone shim (#139)", async () => { + const kv = new InMemoryKV(); + await handleToolCall( + "memory_save", + { content: "Use bcrypt for password hashing" }, + kv, + ); + await handleToolCall( + "memory_save", + { content: "Use argon2id for new projects" }, + kv, + ); + const result = await handleToolCall( + "memory_smart_search", + { query: "bcrypt", limit: 5 }, + kv, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.results).toHaveLength(1); + expect(parsed.results[0].content).toBe("Use bcrypt for password hashing"); + }); + + it("memory_smart_search rejects empty query to prevent match-all in forget flow (#139)", async () => { + const kv = new InMemoryKV(); + await handleToolCall("memory_save", { content: "anything" }, kv); + await expect( + handleToolCall("memory_smart_search", {}, kv), + ).rejects.toThrow("query is required"); + await expect( + handleToolCall("memory_smart_search", { query: "" }, kv), + ).rejects.toThrow("query is required"); + await expect( + handleToolCall("memory_smart_search", { query: " " }, kv), + ).rejects.toThrow("query is required"); + }); + + it("memory_smart_search searches files and concepts, not just title/content (#139)", async () => { + const kv = new InMemoryKV(); + await handleToolCall( + "memory_save", + { + content: "generic note", + concepts: ["oauth", "token-rotation"], + files: ["src/auth/refresh.ts"], + }, + kv, + ); + await handleToolCall("memory_save", { content: "unrelated" }, kv); + + // Find by file path + const byFile = JSON.parse( + ( + await handleToolCall( + "memory_smart_search", + { query: "src/auth/refresh.ts" }, + kv, + ) + ).content[0].text, + ); + expect(byFile.results).toHaveLength(1); + expect(byFile.results[0].files).toContain("src/auth/refresh.ts"); + + // Find by concept + const byConcept = JSON.parse( + ( + await handleToolCall( + "memory_smart_search", + { query: "token-rotation" }, + kv, + ) + ).content[0].text, + ); + expect(byConcept.results).toHaveLength(1); + }); + + it("memory_sessions honours the limit arg (#139)", async () => { + const kv = new InMemoryKV(); + for (let i = 0; i < 5; i++) { + await kv.set("mem:sessions", `ses_${i}`, { + id: `ses_${i}`, + project: "demo", + }); + } + const result = await handleToolCall( + "memory_sessions", + { limit: 2 }, + kv, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.sessions).toHaveLength(2); + }); + + it("parseLimit clamps bad/malicious limit values to a safe range", async () => { + const kv = new InMemoryKV(); + for (let i = 0; i < 150; i++) { + await handleToolCall("memory_save", { content: `mem ${i}` }, kv); + } + + // Negative / NaN / Infinity / string / object — all should fall back + // to the default (10) for memory_smart_search. + for (const bogus of [-1, NaN, Infinity, "abc", {}, true]) { + const r = await handleToolCall( + "memory_smart_search", + { query: "mem", limit: bogus }, + kv, + ); + expect(JSON.parse(r.content[0].text).results).toHaveLength(10); + } + + // An absurdly large limit gets clamped to MAX_LIMIT (100). + const huge = await handleToolCall( + "memory_smart_search", + { query: "mem", limit: 99999 }, + kv, + ); + expect(JSON.parse(huge.content[0].text).results).toHaveLength(100); + }); + + it("memory_governance_delete removes memories by id array (#139)", async () => { + const kv = new InMemoryKV(); + const a = JSON.parse( + (await handleToolCall("memory_save", { content: "one" }, kv)).content[0] + .text, + ); + const b = JSON.parse( + (await handleToolCall("memory_save", { content: "two" }, kv)).content[0] + .text, + ); + const c = JSON.parse( + (await handleToolCall("memory_save", { content: "three" }, kv)).content[0] + .text, + ); + const result = await handleToolCall( + "memory_governance_delete", + { memoryIds: [a.saved, c.saved] }, + kv, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.deleted).toBe(2); + expect(parsed.requested).toBe(2); + + const remaining = await kv.list>("mem:memories"); + expect(remaining).toHaveLength(1); + expect((remaining[0] as { id: string }).id).toBe(b.saved); + }); + + it("memory_governance_delete accepts CSV-string memoryIds too", async () => { + const kv = new InMemoryKV(); + const saved = JSON.parse( + (await handleToolCall("memory_save", { content: "x" }, kv)).content[0] + .text, + ); + const result = await handleToolCall( + "memory_governance_delete", + { memoryIds: saved.saved, reason: "test csv" }, + kv, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.deleted).toBe(1); + expect(parsed.reason).toBe("test csv"); + }); + + it("memory_governance_delete throws when memoryIds is missing or empty", async () => { + const kv = new InMemoryKV(); + await expect( + handleToolCall("memory_governance_delete", {}, kv), + ).rejects.toThrow("memoryIds is required"); + await expect( + handleToolCall("memory_governance_delete", { memoryIds: [] }, kv), + ).rejects.toThrow("memoryIds is required"); + }); + + it("memory_governance_delete silently skips unknown ids", async () => { + const kv = new InMemoryKV(); + const saved = JSON.parse( + (await handleToolCall("memory_save", { content: "real" }, kv)).content[0] + .text, + ); + const result = await handleToolCall( + "memory_governance_delete", + { memoryIds: [saved.saved, "mem_does_not_exist"] }, + kv, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.deleted).toBe(1); + expect(parsed.requested).toBe(2); + }); +}); diff --git a/test/mcp-surface-default.test.ts b/test/mcp-surface-default.test.ts new file mode 100644 index 0000000..07fa235 --- /dev/null +++ b/test/mcp-surface-default.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { + getAllTools, + getVisibleTools, +} from "../src/mcp/tools-registry.js"; + +// plugin manifests and README advertise 51 MCP tools. The old +// default was AGENTMEMORY_TOOLS=core which silently capped the surface +// at 8 essentials with no indication the other 43 existed. Default +// flipped to "all"; the lean set is still accessible via +// AGENTMEMORY_TOOLS=core. +describe("MCP tool surface default (#553)", () => { + const ORIG = process.env["AGENTMEMORY_TOOLS"]; + beforeEach(() => { + delete process.env["AGENTMEMORY_TOOLS"]; + }); + afterEach(() => { + if (ORIG === undefined) delete process.env["AGENTMEMORY_TOOLS"]; + else process.env["AGENTMEMORY_TOOLS"] = ORIG; + }); + + it("default returns the full 51-tool surface, matching plugin advertising", () => { + const visible = getVisibleTools(); + const all = getAllTools(); + expect(visible.length).toBe(all.length); + expect(visible.length).toBeGreaterThanOrEqual(48); + }); + + it("AGENTMEMORY_TOOLS=all returns the same full set", () => { + process.env["AGENTMEMORY_TOOLS"] = "all"; + expect(getVisibleTools().length).toBe(getAllTools().length); + }); + + it("AGENTMEMORY_TOOLS=core returns the 8 essential tools", () => { + process.env["AGENTMEMORY_TOOLS"] = "core"; + const names = new Set(getVisibleTools().map((t) => t.name)); + expect(names.size).toBe(8); + for (const t of [ + "memory_save", + "memory_recall", + "memory_consolidate", + "memory_smart_search", + "memory_sessions", + "memory_diagnose", + "memory_lesson_save", + "memory_reflect", + ]) { + expect(names.has(t)).toBe(true); + } + }); + + it("plugin .mcp.json provides default env interpolation so CC parse never fails (#510)", () => { + const raw = readFileSync("plugin/.mcp.json", "utf-8"); + const cfg = JSON.parse(raw) as { + mcpServers: { agentmemory: { env: Record } }; + }; + const env = cfg.mcpServers.agentmemory.env; + // Per Claude Code MCP docs: ${VAR} without a default fails config + // parse when VAR is unset, silently dropping the server. ${VAR:-x} + // form is what unblocks fresh installs that haven't exported + // AGENTMEMORY_URL. + expect(env["AGENTMEMORY_URL"]).toMatch(/\$\{AGENTMEMORY_URL:-/); + expect(env["AGENTMEMORY_SECRET"]).toMatch(/\$\{AGENTMEMORY_SECRET:-/); + expect(env["AGENTMEMORY_TOOLS"]).toMatch(/\$\{AGENTMEMORY_TOOLS:-all\}/); + }); +}); diff --git a/test/mcp-transport.test.ts b/test/mcp-transport.test.ts new file mode 100644 index 0000000..006ecc9 --- /dev/null +++ b/test/mcp-transport.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi } from "vitest"; +import { + createMessageParser, + formatResponse, + processLine, + type JsonRpcResponse, + type RequestHandler, +} from "../src/mcp/transport.js"; + +function collector() { + const out: JsonRpcResponse[] = []; + const err: string[] = []; + return { + out, + err, + writeOut: (r: JsonRpcResponse) => out.push(r), + writeErr: (m: string) => err.push(m), + }; +} + +const okHandler: RequestHandler = async (method) => ({ method }); + +describe("processLine — request path", () => { + it("emits a response for a request with id", async () => { + const c = collector(); + await processLine( + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }), + okHandler, + c.writeOut, + c.writeErr, + ); + expect(c.out).toHaveLength(1); + expect(c.out[0]).toEqual({ + jsonrpc: "2.0", + id: 1, + result: { method: "initialize" }, + }); + }); + + it("emits an error response when the handler throws on a request", async () => { + const c = collector(); + const throwingHandler: RequestHandler = async () => { + throw new Error("boom"); + }; + await processLine( + JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }), + throwingHandler, + c.writeOut, + c.writeErr, + ); + expect(c.out).toHaveLength(1); + expect(c.out[0].id).toBe(7); + expect(c.out[0].error?.code).toBe(-32603); + expect(c.out[0].error?.message).toBe("boom"); + }); +}); + +describe("processLine — notification path (#129)", () => { + it("does NOT emit a response for a notification (no id field)", async () => { + const c = collector(); + const handlerCalled = vi.fn(async () => ({ shouldNotEscape: true })); + await processLine( + JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + handlerCalled, + c.writeOut, + c.writeErr, + ); + expect(handlerCalled).toHaveBeenCalledOnce(); + expect(c.out).toHaveLength(0); + expect(c.err).toHaveLength(0); + }); + + it("does NOT emit a response for a notification with id: null", async () => { + const c = collector(); + await processLine( + JSON.stringify({ + jsonrpc: "2.0", + id: null, + method: "notifications/cancelled", + }), + okHandler, + c.writeOut, + c.writeErr, + ); + expect(c.out).toHaveLength(0); + }); + + it("logs to stderr but does NOT emit a response when a notification handler throws", async () => { + const c = collector(); + const throwingHandler: RequestHandler = async () => { + throw new Error("notification crash"); + }; + await processLine( + JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + throwingHandler, + c.writeOut, + c.writeErr, + ); + expect(c.out).toHaveLength(0); + expect(c.err).toHaveLength(1); + expect(c.err[0]).toContain("notification handler error"); + expect(c.err[0]).toContain("notification crash"); + }); +}); + +describe("processLine — malformed input", () => { + it("emits a parse error with id: null for invalid JSON", async () => { + const c = collector(); + await processLine("not-json", okHandler, c.writeOut, c.writeErr); + expect(c.out).toHaveLength(1); + expect(c.out[0].id).toBeNull(); + expect(c.out[0].error?.code).toBe(-32700); + expect(c.out[0].error?.message).toBe("Parse error"); + }); + + it("ignores empty / whitespace-only lines", async () => { + const c = collector(); + await processLine("", okHandler, c.writeOut, c.writeErr); + await processLine(" \t ", okHandler, c.writeOut, c.writeErr); + expect(c.out).toHaveLength(0); + expect(c.err).toHaveLength(0); + }); + + it("emits an Invalid Request error when a request has an id but no jsonrpc", async () => { + const c = collector(); + await processLine( + JSON.stringify({ id: 1, method: "tools/list" }), + okHandler, + c.writeOut, + c.writeErr, + ); + expect(c.out).toHaveLength(1); + expect(c.out[0].id).toBe(1); + expect(c.out[0].error?.code).toBe(-32600); + }); + + it("silently drops a malformed message that has no id (treated as notification)", async () => { + const c = collector(); + await processLine( + JSON.stringify({ method: "broken" }), + okHandler, + c.writeOut, + c.writeErr, + ); + // No jsonrpc field, no id — drop without responding. + expect(c.out).toHaveLength(0); + }); + + it("silently drops a malformed message with a non-primitive id (can't safely echo)", async () => { + const c = collector(); + await processLine( + JSON.stringify({ id: { nested: true }, method: "broken" }), + okHandler, + c.writeOut, + c.writeErr, + ); + // Malformed shape + non-primitive id — can't echo id back, drop silently. + expect(c.out).toHaveLength(0); + }); +}); + +describe("processLine — id type validation (JSON-RPC §4)", () => { + it("rejects a request whose id is an object with -32600 and id: null", async () => { + const c = collector(); + const handlerCalled = vi.fn(okHandler); + await processLine( + JSON.stringify({ + jsonrpc: "2.0", + id: { bogus: true }, + method: "tools/list", + }), + handlerCalled, + c.writeOut, + c.writeErr, + ); + expect(handlerCalled).not.toHaveBeenCalled(); + expect(c.out).toHaveLength(1); + expect(c.out[0].id).toBeNull(); + expect(c.out[0].error?.code).toBe(-32600); + expect(c.out[0].error?.message).toContain("id must be"); + }); + + it("rejects a request whose id is an array", async () => { + const c = collector(); + const handlerCalled = vi.fn(okHandler); + await processLine( + JSON.stringify({ jsonrpc: "2.0", id: [1, 2], method: "tools/list" }), + handlerCalled, + c.writeOut, + c.writeErr, + ); + expect(handlerCalled).not.toHaveBeenCalled(); + expect(c.out).toHaveLength(1); + expect(c.out[0].id).toBeNull(); + expect(c.out[0].error?.code).toBe(-32600); + }); + + it("rejects a request whose id is a boolean", async () => { + const c = collector(); + const handlerCalled = vi.fn(okHandler); + await processLine( + JSON.stringify({ jsonrpc: "2.0", id: true, method: "tools/list" }), + handlerCalled, + c.writeOut, + c.writeErr, + ); + expect(handlerCalled).not.toHaveBeenCalled(); + expect(c.out).toHaveLength(1); + expect(c.out[0].id).toBeNull(); + expect(c.out[0].error?.code).toBe(-32600); + }); + + it("accepts a request with string id", async () => { + const c = collector(); + await processLine( + JSON.stringify({ jsonrpc: "2.0", id: "abc-123", method: "ping" }), + okHandler, + c.writeOut, + c.writeErr, + ); + expect(c.out).toHaveLength(1); + expect(c.out[0].id).toBe("abc-123"); + expect(c.out[0].result).toEqual({ method: "ping" }); + }); +}); + +describe("stdio framing", () => { + it("parses Content-Length framed MCP messages split across chunks", () => { + const messages: string[] = []; + const parser = createMessageParser((message) => messages.push(message)); + const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }); + const framed = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`; + + parser.push(framed.slice(0, 12)); + parser.push(framed.slice(12)); + + expect(messages).toEqual([body]); + expect(parser.isFramed()).toBe(true); + }); + + it("parses newline-delimited JSON for existing clients", () => { + const messages: string[] = []; + const parser = createMessageParser((message) => messages.push(message)); + const first = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + const second = JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }); + + parser.push(`${first}\n${second}\n`); + + expect(messages).toEqual([first, second]); + expect(parser.isFramed()).toBe(false); + }); + + it("formats responses with Content-Length framing when requested", () => { + const response: JsonRpcResponse = { + jsonrpc: "2.0", + id: 1, + result: { ok: true }, + }; + const formatted = formatResponse(response, true); + + expect(Array.isArray(formatted)).toBe(true); + if (!Array.isArray(formatted)) throw new Error("expected framed response"); + const header = formatted[0].toString("ascii"); + const body = formatted[1].toString("utf8"); + + expect(header).toBe(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`); + expect(JSON.parse(body)).toEqual(response); + }); +}); diff --git a/test/memories-pagination.test.ts b/test/memories-pagination.test.ts new file mode 100644 index 0000000..c3275df --- /dev/null +++ b/test/memories-pagination.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// /memories and /export must support count + pagination so the +// viewer and `agentmemory status` work on large corpora (8K+ memories) +// without timing out at the iii engine boundary. +describe("memories + export pagination (#544)", () => { + const api = readFileSync("src/triggers/api.ts", "utf-8"); + + it("api::memories accepts count=true and returns total + latestCount", () => { + expect(api).toMatch(/req\.query_params\?\.\["count"\]\s*===\s*"true"/); + // count must report the SAME scope as the list path (#554 follow-up). + expect(api).toMatch(/total:\s*filtered\.length/); + expect(api).toMatch(/latestCount:\s*filtered\.filter/); + }); + + it("api::memories accepts limit + offset query params", () => { + expect(api).toMatch(/query_params\?\.\["limit"\]/); + expect(api).toMatch(/query_params\?\.\["offset"\]/); + expect(api).toMatch(/filtered\.slice\(offset/); + expect(api).toMatch(/total:\s*filtered\.length/); + }); + + it("api::memories caps limit at 5000 to bound response size", () => { + expect(api).toMatch(/Math\.min\(parsedLimit,\s*5000\)/); + }); + + it("api::export passes through maxSessions + offset query params", () => { + expect(api).toMatch(/query_params\?\.\["maxSessions"\]/); + expect(api).toMatch(/query_params\?\.\["offset"\]/); + // The payload object is named `payload` in our handler; assert it is + // forwarded to mem::export rather than the previous empty object. + expect(api).toMatch( + /sdk\.trigger\(\{\s*function_id:\s*"mem::export",\s*payload,/m, + ); + }); + + it("viewer dashboard caps memories?latest fetch with limit", () => { + const viewer = readFileSync("src/viewer/index.html", "utf-8"); + expect(viewer).toMatch(/memories\?latest=true&limit=500/); + expect(viewer).toMatch(/memories\?latest=true&limit=2000/); + }); +}); diff --git a/test/mesh.test.ts b/test/mesh.test.ts new file mode 100644 index 0000000..e08db07 --- /dev/null +++ b/test/mesh.test.ts @@ -0,0 +1,755 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerMeshFunction } from "../src/functions/mesh.js"; +import type { + MeshPeer, + Memory, + Action, + SemanticMemory, + ProceduralMemory, + MemoryRelation, + GraphNode, + GraphEdge, +} from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Mesh Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + vi.clearAllMocks(); + registerMeshFunction(sdk as never, kv as never); + }); + + describe("mesh-register", () => { + it("registers a valid peer", async () => { + const result = (await sdk.trigger("mem::mesh-register", { + url: "https://peer1.example.com", + name: "peer-1", + sharedScopes: ["memories"], + })) as { success: boolean; peer: MeshPeer }; + + expect(result.success).toBe(true); + expect(result.peer.url).toBe("https://peer1.example.com"); + expect(result.peer.name).toBe("peer-1"); + expect(result.peer.status).toBe("disconnected"); + expect(result.peer.sharedScopes).toEqual(["memories"]); + expect(result.peer.id).toMatch(/^peer_/); + + const peers = await kv.list("mem:mesh"); + expect(peers.length).toBe(1); + }); + + it("uses expanded default sharedScopes when not provided", async () => { + const result = (await sdk.trigger("mem::mesh-register", { + url: "https://peer2.example.com", + name: "peer-2", + })) as { success: boolean; peer: MeshPeer }; + + expect(result.success).toBe(true); + expect(result.peer.sharedScopes).toEqual([ + "memories", + "actions", + "semantic", + "procedural", + "relations", + "graph:nodes", + "graph:edges", + ]); + }); + + it("stores syncFilter when provided", async () => { + const result = (await sdk.trigger("mem::mesh-register", { + url: "https://peer3.example.com", + name: "peer-3", + syncFilter: { project: "/my/project" }, + })) as { success: boolean; peer: MeshPeer }; + + expect(result.success).toBe(true); + expect(result.peer.syncFilter).toEqual({ project: "/my/project" }); + }); + + it("returns error when url is missing", async () => { + const result = (await sdk.trigger("mem::mesh-register", { + name: "peer-1", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("url and name are required"); + }); + + it("returns error when name is missing", async () => { + const result = (await sdk.trigger("mem::mesh-register", { + url: "https://peer1.example.com", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("url and name are required"); + }); + + it("returns error for duplicate url", async () => { + await sdk.trigger("mem::mesh-register", { + url: "https://peer1.example.com", + name: "peer-1", + }); + + const result = (await sdk.trigger("mem::mesh-register", { + url: "https://peer1.example.com", + name: "peer-1-duplicate", + })) as { success: boolean; error: string; peerId: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("peer already registered"); + expect(result.peerId).toBeDefined(); + }); + }); + + describe("mesh-list", () => { + it("returns empty list when no peers registered", async () => { + const result = (await sdk.trigger("mem::mesh-list", {})) as { + success: boolean; + peers: MeshPeer[]; + }; + + expect(result.success).toBe(true); + expect(result.peers).toEqual([]); + }); + + it("returns all registered peers", async () => { + await sdk.trigger("mem::mesh-register", { + url: "https://peer1.example.com", + name: "peer-1", + }); + await sdk.trigger("mem::mesh-register", { + url: "https://peer2.example.com", + name: "peer-2", + }); + + const result = (await sdk.trigger("mem::mesh-list", {})) as { + success: boolean; + peers: MeshPeer[]; + }; + + expect(result.success).toBe(true); + expect(result.peers.length).toBe(2); + expect(result.peers.map((p) => p.name).sort()).toEqual(["peer-1", "peer-2"]); + }); + }); + + describe("mesh-sync", () => { + it("requires a configured shared secret", async () => { + const regResult = (await sdk.trigger("mem::mesh-register", { + url: "https://peer1.example.com", + name: "peer-1", + })) as { success: boolean; peer: MeshPeer }; + + const result = (await sdk.trigger("mem::mesh-sync", { + peerId: regResult.peer.id, + direction: "push", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("AGENTMEMORY_SECRET"); + }); + + it("sends authorization headers to peers when syncing", async () => { + const authedSdk = mockSdk(); + const authedKv = mockKV(); + registerMeshFunction(authedSdk as never, authedKv as never, "mesh-secret"); + + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ accepted: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const regResult = (await authedSdk.trigger("mem::mesh-register", { + url: "https://peer2.example.com", + name: "peer-2", + })) as { success: boolean; peer: MeshPeer }; + + const result = (await authedSdk.trigger("mem::mesh-sync", { + peerId: regResult.peer.id, + direction: "push", + })) as { success: boolean; results: Array<{ errors: string[] }> }; + + expect(result.success).toBe(true); + expect(result.results[0].errors).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + "https://peer2.example.com/agentmemory/mesh/receive", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer mesh-secret", + }), + }), + ); + + vi.unstubAllGlobals(); + }); + }); + + describe("mesh-receive", () => { + it("accepts new memories", async () => { + const mem: Memory = { + id: "mem_1", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "pattern", + title: "Test memory", + content: "Test content", + concepts: ["test"], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + memories: [mem], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + + const stored = await kv.get("mem:memories", "mem_1"); + expect(stored).toBeDefined(); + expect(stored!.title).toBe("Test memory"); + }); + + it("accepts newer memory over existing (last-write-wins)", async () => { + const older: Memory = { + id: "mem_1", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "pattern", + title: "Old title", + content: "Old content", + concepts: [], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: true, + }; + await kv.set("mem:memories", "mem_1", older); + + const newer: Memory = { + ...older, + updatedAt: "2026-03-02T00:00:00Z", + title: "New title", + content: "New content", + version: 2, + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + memories: [newer], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + + const stored = await kv.get("mem:memories", "mem_1"); + expect(stored!.title).toBe("New title"); + }); + + it("rejects older memory than existing", async () => { + const existing: Memory = { + id: "mem_1", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-02T00:00:00Z", + type: "pattern", + title: "Existing title", + content: "Existing content", + concepts: [], + files: [], + sessionIds: [], + strength: 5, + version: 2, + isLatest: true, + }; + await kv.set("mem:memories", "mem_1", existing); + + const older: Memory = { + ...existing, + updatedAt: "2026-03-01T00:00:00Z", + title: "Old title", + version: 1, + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + memories: [older], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(0); + + const stored = await kv.get("mem:memories", "mem_1"); + expect(stored!.title).toBe("Existing title"); + }); + + it("skips memory entries with missing id", async () => { + const result = (await sdk.trigger("mem::mesh-receive", { + memories: [ + { updatedAt: "2026-03-01T00:00:00Z", title: "No ID" } as unknown as Memory, + ], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(0); + }); + + it("skips memory entries with invalid date", async () => { + const result = (await sdk.trigger("mem::mesh-receive", { + memories: [ + { + id: "mem_bad_date", + updatedAt: "not-a-date", + title: "Bad date", + } as unknown as Memory, + ], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(0); + }); + + it("accepts new actions", async () => { + const action: Action = { + id: "act_1", + title: "Fix bug", + description: "Fix the login bug", + status: "pending", + priority: 1, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + createdBy: "agent-1", + tags: ["bug"], + sourceObservationIds: [], + sourceMemoryIds: [], + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + actions: [action], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + + const stored = await kv.get("mem:actions", "act_1"); + expect(stored).toBeDefined(); + expect(stored!.title).toBe("Fix bug"); + }); + + it("accepts newer action over existing (last-write-wins)", async () => { + const older: Action = { + id: "act_1", + title: "Old action", + description: "Old desc", + status: "pending", + priority: 1, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + createdBy: "agent-1", + tags: [], + sourceObservationIds: [], + sourceMemoryIds: [], + }; + await kv.set("mem:actions", "act_1", older); + + const newer: Action = { + ...older, + updatedAt: "2026-03-02T00:00:00Z", + title: "Updated action", + status: "done", + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + actions: [newer], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + + const stored = await kv.get("mem:actions", "act_1"); + expect(stored!.title).toBe("Updated action"); + expect(stored!.status).toBe("done"); + }); + + it("rejects older action than existing", async () => { + const existing: Action = { + id: "act_1", + title: "Current action", + description: "Current desc", + status: "active", + priority: 1, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-02T00:00:00Z", + createdBy: "agent-1", + tags: [], + sourceObservationIds: [], + sourceMemoryIds: [], + }; + await kv.set("mem:actions", "act_1", existing); + + const older: Action = { + ...existing, + updatedAt: "2026-03-01T00:00:00Z", + title: "Stale action", + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + actions: [older], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(0); + + const stored = await kv.get("mem:actions", "act_1"); + expect(stored!.title).toBe("Current action"); + }); + + it("skips action entries with missing id", async () => { + const result = (await sdk.trigger("mem::mesh-receive", { + actions: [ + { updatedAt: "2026-03-01T00:00:00Z", title: "No ID" } as unknown as Action, + ], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(0); + }); + + it("skips action entries with invalid date", async () => { + const result = (await sdk.trigger("mem::mesh-receive", { + actions: [ + { + id: "act_bad_date", + updatedAt: "invalid-date-string", + title: "Bad date", + } as unknown as Action, + ], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(0); + }); + + it("accepts both memories and actions in one call", async () => { + const mem: Memory = { + id: "mem_combo", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "fact", + title: "Combo memory", + content: "Content", + concepts: [], + files: [], + sessionIds: [], + strength: 3, + version: 1, + isLatest: true, + }; + const action: Action = { + id: "act_combo", + title: "Combo action", + description: "Desc", + status: "pending", + priority: 2, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + createdBy: "agent-1", + tags: [], + sourceObservationIds: [], + sourceMemoryIds: [], + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + memories: [mem], + actions: [action], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(2); + }); + + it("returns zero accepted for empty arrays", async () => { + const result = (await sdk.trigger("mem::mesh-receive", { + memories: [], + actions: [], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(0); + }); + }); + + describe("mesh-remove", () => { + it("removes a registered peer", async () => { + const regResult = (await sdk.trigger("mem::mesh-register", { + url: "https://peer1.example.com", + name: "peer-1", + })) as { success: boolean; peer: MeshPeer }; + + const result = (await sdk.trigger("mem::mesh-remove", { + peerId: regResult.peer.id, + })) as { success: boolean }; + + expect(result.success).toBe(true); + + const peers = await kv.list("mem:mesh"); + expect(peers.length).toBe(0); + }); + + it("returns error when peerId is missing", async () => { + const result = (await sdk.trigger("mem::mesh-remove", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("peerId is required"); + }); + + it("succeeds silently for non-existent peerId", async () => { + const result = (await sdk.trigger("mem::mesh-remove", { + peerId: "peer_nonexistent", + })) as { success: boolean }; + + expect(result.success).toBe(true); + }); + }); + + describe("mesh-receive expanded scopes", () => { + it("accepts semantic memories", async () => { + const sem: SemanticMemory = { + id: "sem_1", + fact: "React uses JSX", + confidence: 0.9, + sourceSessionIds: ["ses_1"], + sourceMemoryIds: ["mem_1"], + accessCount: 1, + lastAccessedAt: "2026-03-01T00:00:00Z", + strength: 7, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + semantic: [sem], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + const stored = await kv.get("mem:semantic", "sem_1"); + expect(stored).toBeDefined(); + expect(stored!.fact).toBe("React uses JSX"); + }); + + it("accepts procedural memories", async () => { + const proc: ProceduralMemory = { + id: "proc_1", + name: "Deploy to prod", + steps: ["build", "test", "deploy"], + triggerCondition: "on merge to main", + frequency: 5, + sourceSessionIds: ["ses_1"], + strength: 8, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + procedural: [proc], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + const stored = await kv.get("mem:procedural", "proc_1"); + expect(stored!.name).toBe("Deploy to prod"); + }); + + it("accepts graph nodes", async () => { + const node: GraphNode = { + id: "gn_1", + type: "concept", + name: "typescript", + properties: {}, + sourceObservationIds: ["obs_1"], + createdAt: "2026-03-01T00:00:00Z", + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + graphNodes: [node], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + const stored = await kv.get("mem:graph:nodes", "gn_1"); + expect(stored!.name).toBe("typescript"); + }); + + it("accepts graph edges", async () => { + const edge: GraphEdge = { + id: "ge_1", + type: "uses", + sourceNodeId: "gn_1", + targetNodeId: "gn_2", + weight: 1, + sourceObservationIds: ["obs_1"], + createdAt: "2026-03-01T00:00:00Z", + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + graphEdges: [edge], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + const stored = await kv.get("mem:graph:edges", "ge_1"); + expect(stored!.type).toBe("uses"); + }); + + it("accepts relations", async () => { + const rel: MemoryRelation = { + type: "supersedes", + sourceId: "mem_2", + targetId: "mem_1", + createdAt: "2026-03-01T00:00:00Z", + confidence: 0.95, + }; + const relWithId = { ...rel, id: "rel_1" } as MemoryRelation & { id: string }; + + const result = (await sdk.trigger("mem::mesh-receive", { + relations: [relWithId], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + }); + + it("accepts all scope types in one call", async () => { + const mem: Memory = { + id: "mem_all", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "fact", + title: "All scopes test", + content: "Content", + concepts: [], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: true, + }; + const sem: SemanticMemory = { + id: "sem_all", + fact: "Test", + confidence: 0.5, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 0, + lastAccessedAt: "2026-03-01T00:00:00Z", + strength: 5, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + }; + const node: GraphNode = { + id: "gn_all", + type: "file", + name: "test.ts", + properties: {}, + sourceObservationIds: [], + createdAt: "2026-03-01T00:00:00Z", + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + memories: [mem], + semantic: [sem], + graphNodes: [node], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(3); + }); + + it("applies LWW for semantic memories", async () => { + const older: SemanticMemory = { + id: "sem_lww", + fact: "Old fact", + confidence: 0.5, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 1, + lastAccessedAt: "2026-03-01T00:00:00Z", + strength: 5, + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + }; + await kv.set("mem:semantic", "sem_lww", older); + + const newer: SemanticMemory = { + ...older, + fact: "New fact", + updatedAt: "2026-03-02T00:00:00Z", + }; + + const result = (await sdk.trigger("mem::mesh-receive", { + semantic: [newer], + })) as { success: boolean; accepted: number }; + + expect(result.success).toBe(true); + expect(result.accepted).toBe(1); + const stored = await kv.get("mem:semantic", "sem_lww"); + expect(stored!.fact).toBe("New fact"); + }); + }); +}); diff --git a/test/minimax-provider.test.ts b/test/minimax-provider.test.ts new file mode 100644 index 0000000..4167d4b --- /dev/null +++ b/test/minimax-provider.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, afterEach } from "vitest"; +import { MinimaxProvider } from "../src/providers/minimax.js"; + +describe("MinimaxProvider — base URL resolution (#285)", () => { + const originalEnv = process.env["MINIMAX_BASE_URL"]; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env["MINIMAX_BASE_URL"]; + } else { + process.env["MINIMAX_BASE_URL"] = originalEnv; + } + }); + + it("defaults to https://api.minimax.io/anthropic (not the legacy minimaxi.com host)", () => { + delete process.env["MINIMAX_BASE_URL"]; + const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800); + expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( + "https://api.minimax.io/anthropic", + ); + }); + + it("honors MINIMAX_BASE_URL via getEnvVar (merged ~/.agentmemory/.env + process.env)", () => { + process.env["MINIMAX_BASE_URL"] = "https://custom.example.com/anthropic"; + const provider = new MinimaxProvider("test-key", "MiniMax-M2.7", 800); + expect((provider as unknown as { baseUrl: string }).baseUrl).toBe( + "https://custom.example.com/anthropic", + ); + }); +}); diff --git a/test/multi-instance-port.test.ts b/test/multi-instance-port.test.ts new file mode 100644 index 0000000..4343365 --- /dev/null +++ b/test/multi-instance-port.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { loadConfig } from "../src/config"; + +const PORT_ENVS = [ + "III_REST_PORT", + "III_STREAM_PORT", + "III_STREAMS_PORT", + "III_ENGINE_PORT", + "III_ENGINE_URL", +] as const; + +describe("multi-instance port auto-derive (#750)", () => { + const saved: Record = {}; + + beforeEach(() => { + for (const k of PORT_ENVS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of PORT_ENVS) { + if (saved[k] === undefined) { + delete process.env[k]; + } else { + process.env[k] = saved[k]; + } + } + }); + + it("default REST anchor yields canonical 3111/3112/49134 quartet", () => { + const cfg = loadConfig(); + expect(cfg.restPort).toBe(3111); + expect(cfg.streamsPort).toBe(3112); + expect(cfg.engineUrl).toBe("ws://localhost:49134"); + }); + + it("relocating REST drags streams + engine with it", () => { + process.env["III_REST_PORT"] = "3211"; + const cfg = loadConfig(); + expect(cfg.restPort).toBe(3211); + expect(cfg.streamsPort).toBe(3212); + expect(cfg.engineUrl).toBe("ws://localhost:49234"); + }); + + it("instance N=2 block (3311) lands on 3312 + 49334", () => { + process.env["III_REST_PORT"] = "3311"; + const cfg = loadConfig(); + expect(cfg.restPort).toBe(3311); + expect(cfg.streamsPort).toBe(3312); + expect(cfg.engineUrl).toBe("ws://localhost:49334"); + }); + + it("explicit III_STREAM_PORT pins streams without affecting REST/engine", () => { + process.env["III_REST_PORT"] = "3211"; + process.env["III_STREAM_PORT"] = "9999"; + const cfg = loadConfig(); + expect(cfg.restPort).toBe(3211); + expect(cfg.streamsPort).toBe(9999); + expect(cfg.engineUrl).toBe("ws://localhost:49234"); + }); + + it("legacy III_STREAMS_PORT still honored", () => { + process.env["III_STREAMS_PORT"] = "9000"; + const cfg = loadConfig(); + expect(cfg.streamsPort).toBe(9000); + }); + + it("explicit III_ENGINE_PORT pins engine without affecting REST/streams", () => { + process.env["III_REST_PORT"] = "3211"; + process.env["III_ENGINE_PORT"] = "55555"; + const cfg = loadConfig(); + expect(cfg.restPort).toBe(3211); + expect(cfg.streamsPort).toBe(3212); + expect(cfg.engineUrl).toBe("ws://localhost:55555"); + }); + + it("legacy III_ENGINE_URL overrides derivation entirely", () => { + process.env["III_REST_PORT"] = "3211"; + process.env["III_ENGINE_URL"] = "ws://remote-host:49999"; + const cfg = loadConfig(); + expect(cfg.engineUrl).toBe("ws://remote-host:49999"); + }); +}); diff --git a/test/multimodal.test.ts b/test/multimodal.test.ts new file mode 100644 index 0000000..8f61f88 --- /dev/null +++ b/test/multimodal.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect, vi, afterAll, beforeEach } from "vitest"; +import { existsSync, rmSync } from "node:fs"; + +vi.mock("iii-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getContext: () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() }, + }), + }; +}); + +vi.mock("../src/functions/search.js", () => ({ + getSearchIndex: () => ({ + add: vi.fn(), + }), + vectorIndexAddGuarded: vi.fn().mockResolvedValue(false), +})); + +const mockTrigger = vi.fn().mockResolvedValue(undefined); +const mockSdk = { trigger: mockTrigger } as any; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + if (!store.has(scope)) return []; + return Array.from(store.get(scope)!.values()) as T[]; + }, + getStore: () => store, + }; +} + +const kv = mockKV() as any; + +import { registerObserveFunction } from "../src/functions/observe.js"; +import { registerCompressFunction } from "../src/functions/compress.js"; +import type { RawObservation, CompressedObservation, MemoryProvider } from "../src/types.js"; + +const VALID_COMPRESS_XML = `image +Screenshot of Red Dot +Test image observation +Image shows a single red pixel on white background +A vision model described a screenshot showing a red dot on a white background +testingscreenshot + +5`; + +describe("End-to-End Multimodal Flow", () => { + let savedImagePath: string | undefined; + + afterAll(() => { + if (savedImagePath && existsSync(savedImagePath)) { + rmSync(savedImagePath); + console.log(`Cleanup: Removed test image at ${savedImagePath}`); + } + }); + + beforeEach(() => { + mockTrigger.mockClear(); + }); + + it("Step 1: Agent image should be successfully saved to hard drive", async () => { + let observeCallback: any = null; + const sdkMocker = { ...mockSdk, registerFunction: vi.fn((id, cb) => { if (id === "mem::observe") observeCallback = cb; }) }; + registerObserveFunction(sdkMocker, kv); + + const fakeIncomingData = { + hookType: "post_tool_use", + sessionId: "test-session", + timestamp: new Date().toISOString(), + data: { + tool_name: "screenshot", + tool_output: { + image_data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==" + } + } + }; + + const res = await observeCallback(fakeIncomingData); + expect(res.observationId).toBeDefined(); + + const obsList = await kv.list("mem:obs:test-session"); + expect(obsList.length).toBe(1); + + const raw = obsList[0] as RawObservation; + expect(raw.modality).toBe("mixed"); + + expect(raw.imageData).toBeDefined(); + expect(typeof raw.imageData).toBe("string"); + expect(existsSync(raw.imageData!)).toBe(true); + + savedImagePath = raw.imageData; + + const deltaCalls = mockTrigger.mock.calls.filter( + (c: any[]) => c[0]?.function_id === "mem::disk-size-delta" + ); + expect(deltaCalls.length).toBe(1); + expect(deltaCalls[0][0].payload.deltaBytes).toBeGreaterThan(0); + }); + + it("Step 2 & 3: mem::compress should call the vision model and store compressed observation in KV", async () => { + const mockProvider: MemoryProvider = { + name: "mock-vision", + compress: async (_systemPrompt, userPrompt) => { + expect(userPrompt).toContain("TEST_VISION_RESULT: I see a red dot"); + return VALID_COMPRESS_XML; + }, + summarize: async () => "", + describeImage: async (_base64, _mimeType, _prompt) => { + return "TEST_VISION_RESULT: I see a red dot"; + }, + }; + + let compressCallback: any = null; + const sdkMocker = { + ...mockSdk, + registerFunction: vi.fn((id, cb) => { + if (id === "mem::compress") compressCallback = cb; + }), + }; + registerCompressFunction(sdkMocker, kv, mockProvider); + + expect(compressCallback).not.toBeNull(); + + const rawObsList = await kv.list("mem:obs:test-session"); + const raw = rawObsList[0] as RawObservation; + + expect(raw.modality).toBeDefined(); + expect(raw.imageData).toBe(savedImagePath); + + const result = await compressCallback({ + observationId: raw.id, + sessionId: raw.sessionId, + raw, + }); + + expect(result.success).toBe(true); + expect(result.compressed).toBeDefined(); + + const compressed = result.compressed as CompressedObservation; + expect(compressed.imageDescription).toBe("TEST_VISION_RESULT: I see a red dot"); + expect(compressed.imageRef).toBe(savedImagePath); + expect(compressed.modality).toBe("mixed"); + expect(compressed.title).toBe("Screenshot of Red Dot"); + expect(compressed.narrative).toContain("red dot"); + + const stored = await kv.get("mem:obs:test-session", raw.id!) as CompressedObservation | null; + expect(stored).not.toBeNull(); + expect(stored!.imageDescription).toBe("TEST_VISION_RESULT: I see a red dot"); + expect(stored!.imageRef).toBe(savedImagePath); + }); +}); + +describe("Disk Size Manager", () => { + it("should increment disk size and trigger cleanup when over quota", async () => { + const localKv = mockKV() as any; + const localTrigger = vi.fn().mockResolvedValue(undefined); + const localSdk = { trigger: localTrigger } as any; + + let managerCallback: any = null; + const sdkMocker = { + ...localSdk, + registerFunction: vi.fn((id: string, cb: any) => { + if (id === "mem::disk-size-delta") managerCallback = cb; + }), + }; + + const { registerDiskSizeManager } = await import("../src/functions/disk-size-manager.js"); + registerDiskSizeManager(sdkMocker, localKv); + + expect(managerCallback).not.toBeNull(); + + const res1 = await managerCallback({ deltaBytes: 1000 }); + expect(res1.success).toBe(true); + expect(res1.currentTotal).toBe(1000); + + const res2 = await managerCallback({ deltaBytes: 2000 }); + expect(res2.success).toBe(true); + expect(res2.currentTotal).toBe(3000); + + expect(localTrigger).not.toHaveBeenCalled(); + }); + + it("should trigger cleanup when total exceeds max bytes", async () => { + const originalEnv = process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES; + try { + process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES = "5000"; + + const localKv = mockKV() as any; + const localTrigger = vi.fn().mockResolvedValue(undefined); + const localSdk = { trigger: localTrigger } as any; + + let managerCallback: any = null; + const sdkMocker = { + ...localSdk, + registerFunction: vi.fn((id: string, cb: any) => { + if (id === "mem::disk-size-delta") managerCallback = cb; + }), + }; + + const { registerDiskSizeManager } = await import("../src/functions/disk-size-manager.js"); + registerDiskSizeManager(sdkMocker, localKv); + + await managerCallback({ deltaBytes: 3000 }); + expect(localTrigger).not.toHaveBeenCalled(); + + await managerCallback({ deltaBytes: 3000 }); + expect(localTrigger).toHaveBeenCalledWith( + expect.objectContaining({ + function_id: "mem::image-quota-cleanup", + payload: {}, + }), + ); + } finally { + if (originalEnv === undefined) { + delete process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES; + } else { + process.env.AGENTMEMORY_IMAGE_STORE_MAX_BYTES = originalEnv; + } + } + }); + + it("should clamp negative totals to zero", async () => { + const localKv = mockKV() as any; + const localSdk = { trigger: vi.fn().mockResolvedValue(undefined) } as any; + + let managerCallback: any = null; + const sdkMocker = { + ...localSdk, + registerFunction: vi.fn((id: string, cb: any) => { + if (id === "mem::disk-size-delta") managerCallback = cb; + }), + }; + + const { registerDiskSizeManager } = await import("../src/functions/disk-size-manager.js"); + registerDiskSizeManager(sdkMocker, localKv); + + const res = await managerCallback({ deltaBytes: -99999 }); + expect(res.currentTotal).toBe(0); + }); + + it("should reject invalid deltaBytes", async () => { + const localKv = mockKV() as any; + const localSdk = { trigger: vi.fn().mockResolvedValue(undefined) } as any; + + let managerCallback: any = null; + const sdkMocker = { + ...localSdk, + registerFunction: vi.fn((id: string, cb: any) => { + if (id === "mem::disk-size-delta") managerCallback = cb; + }), + }; + + const { registerDiskSizeManager } = await import("../src/functions/disk-size-manager.js"); + registerDiskSizeManager(sdkMocker, localKv); + + const res = await managerCallback({ deltaBytes: "not-a-number" }); + expect(res.success).toBe(false); + }); +}); + +describe("Image Refs", () => { + it("should increment and decrement ref counts correctly with deletion parity", async () => { + const localKv = mockKV() as any; + const localTrigger = vi.fn().mockResolvedValue(undefined); + const localSdk = { trigger: localTrigger } as any; + + const { saveImageToDisk } = await import("../src/utils/image-store.js"); + const { incrementImageRef, decrementImageRef, getImageRefCount } = await import("../src/functions/image-refs.js"); + + const result = await saveImageToDisk( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==" + ); + const testFile = result.filePath; + + expect(existsSync(testFile)).toBe(true); + + // Initial state + expect(await getImageRefCount(localKv, testFile)).toBe(0); + + // Increment to 1 + await incrementImageRef(localKv, testFile); + expect(await getImageRefCount(localKv, testFile)).toBe(1); + + // Increment to 2 (shared image) + await incrementImageRef(localKv, testFile); + expect(await getImageRefCount(localKv, testFile)).toBe(2); + + // Decrement from 2 to 1 + await decrementImageRef(localKv, localSdk, testFile); + expect(await getImageRefCount(localKv, testFile)).toBe(1); + + // (c) shared image with refcount >= 2 is NOT deleted when one decrements + expect(existsSync(testFile)).toBe(true); + expect(localTrigger).not.toHaveBeenCalled(); + + // (a) decrementing to zero triggers image file deletion and negative delta + await decrementImageRef(localKv, localSdk, testFile); + expect(await getImageRefCount(localKv, testFile)).toBe(0); + expect(existsSync(testFile)).toBe(false); + + const deltaCalls = localTrigger.mock.calls.filter( + (c: any[]) => c[0]?.function_id === "mem::disk-size-delta" + ); + expect(deltaCalls.length).toBe(1); + expect(deltaCalls[0][0].payload.deltaBytes).toBeLessThan(0); + + // (b) decrementing an already-zero/unknown ref is a no-op + localTrigger.mockClear(); + await decrementImageRef(localKv, localSdk, "/fake/unknown/path.png"); + expect(await getImageRefCount(localKv, "/fake/unknown/path.png")).toBe(0); + const noOpDeltaCalls = localTrigger.mock.calls.filter( + (c: any[]) => c[0]?.function_id === "mem::disk-size-delta" + ); + expect(noOpDeltaCalls.length).toBe(0); + }); +}); + +describe("Image Store", () => { + let testFilePath: string | undefined; + + afterAll(() => { + if (testFilePath && existsSync(testFilePath)) { + rmSync(testFilePath); + } + }); + + it("saveImageToDisk should return filePath and bytesWritten", async () => { + const { saveImageToDisk } = await import("../src/utils/image-store.js"); + const result = await saveImageToDisk( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==" + ); + + expect(result.filePath).toBeDefined(); + expect(typeof result.filePath).toBe("string"); + expect(result.filePath.length).toBeGreaterThan(0); + expect(result.bytesWritten).toBeGreaterThan(0); + expect(existsSync(result.filePath)).toBe(true); + + testFilePath = result.filePath; + }); + + it("deleteImage should return deletedBytes after successful deletion", async () => { + const { deleteImage } = await import("../src/utils/image-store.js"); + + expect(testFilePath).toBeDefined(); + expect(existsSync(testFilePath!)).toBe(true); + + const result = await deleteImage(testFilePath!); + expect(result.deletedBytes).toBeGreaterThan(0); + expect(existsSync(testFilePath!)).toBe(false); + + testFilePath = undefined; + }); + + it("deleteImage should return 0 for non-existent files", async () => { + const { deleteImage } = await import("../src/utils/image-store.js"); + const result = await deleteImage("/non/existent/file.png"); + expect(result.deletedBytes).toBe(0); + }); + + it("saveImageToDisk should return empty for empty input", async () => { + const { saveImageToDisk } = await import("../src/utils/image-store.js"); + const result = await saveImageToDisk(""); + expect(result.filePath).toBe(""); + expect(result.bytesWritten).toBe(0); + }); +}); diff --git a/test/observe-implicit-session.test.ts b/test/observe-implicit-session.test.ts new file mode 100644 index 0000000..b0272bc --- /dev/null +++ b/test/observe-implicit-session.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + update: async (scope: string, key: string, updates: Array<{ path: string; value: unknown }>) => { + const m = store.get(scope); + if (!m) return; + const v = (m.get(key) as Record) ?? {}; + for (const u of updates) v[u.path] = u.value; + m.set(key, v); + }, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const fns = new Map(); + return { + fns, + registerFunction: ( + idOrOpts: string | { id: string }, + fn: Function, + ) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + fns.set(id, fn); + }, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown; action?: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = fns.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +describe("observe implicit session create (#638)", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("creates the session on first observe when project+cwd present and session record missing", async () => { + const { registerObserveFunction } = await import("../src/functions/observe.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never); + + const result = (await sdk.trigger("mem::observe", { + sessionId: "ses_opencode_abc", + project: "/home/user/myrepo", + cwd: "/home/user/myrepo", + hookType: "prompt_submit", + timestamp: new Date().toISOString(), + data: { prompt: "ship the helm chart" }, + })) as { observationId: string }; + + expect(result.observationId).toBeTruthy(); + + const sessionScope = kv.store.get("mem:sessions"); + expect(sessionScope).toBeTruthy(); + const session = sessionScope!.get("ses_opencode_abc") as Record; + expect(session).toBeTruthy(); + expect(session.id).toBe("ses_opencode_abc"); + expect(session.project).toBe("/home/user/myrepo"); + expect(session.cwd).toBe("/home/user/myrepo"); + expect(session.status).toBe("active"); + expect(session.observationCount).toBe(1); + expect(session.firstPrompt).toBe("ship the helm chart"); + }); + + it("does not implicit-create when project+cwd missing (test-payload back-compat)", async () => { + const { registerObserveFunction } = await import("../src/functions/observe.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never); + + await sdk.trigger("mem::observe", { + sessionId: "ses_no_project", + hookType: "post_tool_use", + timestamp: new Date().toISOString(), + data: { tool_name: "Read", tool_input: { file_path: "x.ts" } }, + }); + + const sessionScope = kv.store.get("mem:sessions"); + // Either no scope at all, or no entry for this session + expect(sessionScope?.get("ses_no_project")).toBeUndefined(); + }); + + it("does not overwrite an existing session when one already exists", async () => { + const { registerObserveFunction } = await import("../src/functions/observe.js"); + const sdk = mockSdk(); + const kv = mockKV(); + registerObserveFunction(sdk as never, kv as never); + + await kv.set("mem:sessions", "ses_existing", { + id: "ses_existing", + project: "/orig/project", + cwd: "/orig/cwd", + startedAt: "2026-01-01T00:00:00Z", + status: "active", + observationCount: 7, + firstPrompt: "original first prompt", + }); + + await sdk.trigger("mem::observe", { + sessionId: "ses_existing", + project: "/different/project", + cwd: "/different/cwd", + hookType: "post_tool_use", + timestamp: new Date().toISOString(), + data: { tool_name: "Read" }, + }); + + const session = kv.store.get("mem:sessions")!.get("ses_existing") as Record; + // Original project + firstPrompt preserved + expect(session.project).toBe("/orig/project"); + expect(session.firstPrompt).toBe("original first prompt"); + // Counter bumped, updatedAt refreshed + expect(session.observationCount).toBe(8); + expect(session.updatedAt).toBeTruthy(); + }); +}); diff --git a/test/obsidian-export.test.ts b/test/obsidian-export.test.ts new file mode 100644 index 0000000..31394bc --- /dev/null +++ b/test/obsidian-export.test.ts @@ -0,0 +1,433 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const writtenFiles = new Map(); +const createdDirs = new Set(); + +vi.mock("node:fs/promises", () => ({ + mkdir: vi.fn(async (dir: string) => { + createdDirs.add(dir); + }), + writeFile: vi.fn(async (path: string, content: string) => { + writtenFiles.set(path, content); + }), +})); + +import { registerObsidianExportFunction } from "../src/functions/obsidian-export.js"; +import type { Memory, Lesson, Crystal, Session } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeMemory(id: string): Memory { + return { + id, + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-04-01T00:00:00Z", + type: "pattern", + title: `Memory ${id}`, + content: `Content for ${id}`, + concepts: ["testing"], + files: ["src/test.ts"], + sessionIds: ["ses_1"], + strength: 7, + version: 1, + isLatest: true, + }; +} + +function makeLesson(id: string): Lesson { + return { + id, + content: `Lesson ${id}`, + context: "Test context", + confidence: 0.8, + reinforcements: 2, + source: "manual", + sourceIds: [], + project: "/test", + tags: ["testing"], + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-04-01T00:00:00Z", + decayRate: 0.05, + }; +} + +function makeCrystal(id: string): Crystal { + return { + id, + narrative: `Crystal narrative ${id}`, + keyOutcomes: ["Outcome 1"], + filesAffected: ["src/test.ts"], + lessons: ["Learned something"], + sourceActionIds: ["act_1"], + sessionId: "ses_1", + project: "/test", + createdAt: "2026-04-01T00:00:00Z", + }; +} + +function makeSession(id: string): Session { + return { + id, + project: "/test", + cwd: "/Users/test/project", + startedAt: "2026-04-01T00:00:00Z", + endedAt: "2026-04-01T01:00:00Z", + status: "completed", + observationCount: 15, + }; +} + +describe("Obsidian Export", () => { + let sdk: ReturnType; + let kv: ReturnType; + const exportRoot = "/tmp/agentmemory-export-root"; + + beforeEach(() => { + process.env.AGENTMEMORY_EXPORT_ROOT = exportRoot; + sdk = mockSdk(); + kv = mockKV(); + writtenFiles.clear(); + createdDirs.clear(); + registerObsidianExportFunction(sdk as never, kv as never); + }); + + it("creates directories and MOC.md with empty data", async () => { + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + vaultDir: string; + }; + + expect(result.success).toBe(true); + expect(result.exported.memories).toBe(0); + expect(result.exported.lessons).toBe(0); + expect(result.exported.crystals).toBe(0); + expect(result.exported.sessions).toBe(0); + expect(createdDirs.size).toBe(4); + + const mocPath = [...writtenFiles.keys()].find((k) => k.endsWith("MOC.md")); + expect(mocPath).toBeDefined(); + const moc = writtenFiles.get(mocPath!); + expect(moc).toContain("# agentmemory vault"); + expect(moc).toContain("## Memories (0)"); + }); + + it("exports memories with YAML frontmatter", async () => { + const mem = makeMemory("mem_001"); + await kv.set("mem:memories", mem.id, mem); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + exported: Record; + }; + + expect(result.exported.memories).toBe(1); + + const memFile = [...writtenFiles.entries()].find(([k]) => + k.includes("memories/mem_001.md"), + ); + expect(memFile).toBeDefined(); + const content = memFile![1]; + expect(content).toContain("---"); + expect(content).toContain('id: "mem_001"'); + expect(content).toContain('type: "pattern"'); + expect(content).toContain("# Memory mem_001"); + expect(content).toContain("#testing"); + }); + + it("exports lessons with confidence and source", async () => { + const lesson = makeLesson("lsn_001"); + await kv.set("mem:lessons", lesson.id, lesson); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + exported: Record; + }; + + expect(result.exported.lessons).toBe(1); + + const lsnFile = [...writtenFiles.entries()].find(([k]) => + k.includes("lessons/lsn_001.md"), + ); + expect(lsnFile).toBeDefined(); + const content = lsnFile![1]; + expect(content).toContain("confidence: 0.8"); + expect(content).toContain("reinforcements: 2"); + expect(content).toContain('source: "manual"'); + }); + + it("exports crystals with wikilinks to source actions", async () => { + const crystal = makeCrystal("crys_001"); + await kv.set("mem:crystals", crystal.id, crystal); + + await sdk.trigger("mem::obsidian-export", {}); + + const crysFile = [...writtenFiles.entries()].find(([k]) => + k.includes("crystals/crys_001.md"), + ); + expect(crysFile).toBeDefined(); + expect(crysFile![1]).toContain("[[act_1]]"); + expect(crysFile![1]).toContain("Outcome 1"); + }); + + it("respects types filter", async () => { + await kv.set("mem:memories", "mem_001", makeMemory("mem_001")); + await kv.set("mem:lessons", "lsn_001", makeLesson("lsn_001")); + + const result = (await sdk.trigger("mem::obsidian-export", { + types: ["lessons"], + })) as { exported: Record }; + + expect(result.exported.lessons).toBe(1); + expect(result.exported.memories).toBe(0); + }); + + it("respects custom vaultDir", async () => { + await sdk.trigger("mem::obsidian-export", { + vaultDir: "/tmp/agentmemory-export-root/test-vault", + }); + + const hasCustomPath = [...createdDirs].some((d) => + d.startsWith("/tmp/agentmemory-export-root/test-vault"), + ); + expect(hasCustomPath).toBe(true); + }); + + it("rejects vaultDir outside the export root", async () => { + const result = (await sdk.trigger("mem::obsidian-export", { + vaultDir: "/tmp/outside-root", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain(exportRoot); + }); + + it("skips deleted lessons", async () => { + const lesson = makeLesson("lsn_deleted"); + (lesson as any).deleted = true; + await kv.set("mem:lessons", lesson.id, lesson); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + exported: Record; + }; + + expect(result.exported.lessons).toBe(0); + }); + + it("skips non-latest memories", async () => { + const mem = makeMemory("mem_old"); + mem.isLatest = false; + await kv.set("mem:memories", mem.id, mem); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + exported: Record; + }; + + expect(result.exported.memories).toBe(0); + }); + + it("MOC groups entries by type", async () => { + await kv.set("mem:memories", "mem_001", makeMemory("mem_001")); + await kv.set("mem:lessons", "lsn_001", makeLesson("lsn_001")); + await kv.set("mem:crystals", "crys_001", makeCrystal("crys_001")); + await kv.set("mem:sessions", "ses_001", makeSession("ses_001")); + + await sdk.trigger("mem::obsidian-export", {}); + + const mocPath = [...writtenFiles.keys()].find((k) => k.endsWith("MOC.md")); + const moc = writtenFiles.get(mocPath!)!; + + expect(moc).toContain("## Memories (1)"); + expect(moc).toContain("## Lessons (1)"); + expect(moc).toContain("## Crystals (1)"); + expect(moc).toContain("## Sessions (1)"); + expect(moc).toContain("[[memories/mem_001|"); + expect(moc).toContain("[[lessons/lsn_001|"); + expect(moc).toContain("[[crystals/crys_001|"); + expect(moc).toContain("[[sessions/ses_001|"); + }); + + it("returns undefined errors on success", async () => { + await kv.set("mem:memories", "mem_001", makeMemory("mem_001")); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + errors?: unknown[]; + }; + + expect(result.success).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + // #729: any record missing an id used to crash `sanitize(undefined.id)` + // outside the per-record try, escaping the handler entirely and + // returning HTTP 500 `{"error":"[object Object]"}` with zero files + // written. The hardened loops filter id-less records and the outer + // try/catch keeps thrown errors from ever reaching the HTTP serializer. + it("skips records that are missing an id and keeps exporting the rest", async () => { + await kv.set("mem:memories", "orphan-memory", { ...makeMemory("mem_missing"), id: undefined } as any); + await kv.set("mem:lessons", "orphan-lesson", { ...makeLesson("lsn_missing"), id: undefined } as any); + await kv.set("mem:crystals", "orphan-crystal", { ...makeCrystal("crys_missing"), id: undefined } as any); + await kv.set("mem:sessions", "orphan-session", { ...makeSession("ses_missing"), id: undefined } as any); + await kv.set("mem:sessions", "valid-session", makeSession("ses_valid")); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + errors?: unknown[]; + }; + + expect(result.success).toBe(true); + expect(result.exported.memories).toBe(0); + expect(result.exported.lessons).toBe(0); + expect(result.exported.crystals).toBe(0); + expect(result.exported.sessions).toBe(1); + expect(result.errors).toBeUndefined(); + expect([...writtenFiles.keys()].some((path) => path.includes("undefined.md"))).toBe(false); + expect([...writtenFiles.keys()].some((path) => path.includes("sessions/ses_valid.md"))).toBe(true); + }); + + it("tolerates malformed startedAt timestamps when sorting sessions", async () => { + await kv.set("mem:sessions", "ses_recent", { ...makeSession("ses_recent"), startedAt: "2026-04-02T00:00:00Z" }); + await kv.set("mem:sessions", "ses_bad", { ...makeSession("ses_bad"), startedAt: "not-a-date" } as any); + await kv.set("mem:sessions", "ses_undef", { ...makeSession("ses_undef"), startedAt: undefined } as any); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + }; + + expect(result.success).toBe(true); + expect(result.exported.sessions).toBe(3); + }); + + it("exports memories whose optional array fields are missing or null", async () => { + const incomplete = { + ...makeMemory("mem_incomplete"), + concepts: undefined, + files: null, + relatedIds: null, + supersedes: undefined, + } as any; + await kv.set("mem:memories", incomplete.id, incomplete); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + }; + + expect(result.success).toBe(true); + expect(result.exported.memories).toBe(1); + + const memFile = [...writtenFiles.entries()].find(([k]) => + k.includes("memories/mem_incomplete.md"), + ); + expect(memFile).toBeDefined(); + const content = memFile![1]; + expect(content).toContain("# Memory mem_incomplete"); + expect(content).not.toContain("## Related"); + expect(content).not.toContain("## Supersedes"); + }); + + it("falls back to the id when title / content / narrative are missing", async () => { + await kv.set("mem:memories", "mem_no_title", { + ...makeMemory("mem_no_title"), + title: undefined, + content: undefined, + } as any); + await kv.set("mem:lessons", "lsn_no_content", { + ...makeLesson("lsn_no_content"), + content: undefined, + } as any); + await kv.set("mem:crystals", "crys_no_narr", { + ...makeCrystal("crys_no_narr"), + narrative: undefined, + } as any); + + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + exported: Record; + }; + + expect(result.success).toBe(true); + expect(result.exported.memories).toBe(1); + expect(result.exported.lessons).toBe(1); + expect(result.exported.crystals).toBe(1); + + const memFile = [...writtenFiles.entries()].find(([k]) => + k.includes("memories/mem_no_title.md"), + ); + expect(memFile![1]).toContain("# mem_no_title"); + + const lsnFile = [...writtenFiles.entries()].find(([k]) => + k.includes("lessons/lsn_no_content.md"), + ); + expect(lsnFile![1]).toContain("# Lesson: lsn_no_content"); + + const crysFile = [...writtenFiles.entries()].find(([k]) => + k.includes("crystals/crys_no_narr.md"), + ); + expect(crysFile![1]).toContain("# Crystal: crys_no_narr"); + }); + + it("never throws out to the engine — returns {success: false, error: } on internal failure", async () => { + // Force mkdir to throw to simulate an unexpected runtime error so we + // can assert the outer try/catch turns it into a serializable error. + const fsModule = await import("node:fs/promises"); + const original = fsModule.mkdir; + (fsModule.mkdir as any) = vi.fn(async () => { + throw new TypeError("simulated disk failure"); + }); + + try { + const result = (await sdk.trigger("mem::obsidian-export", {})) as { + success: boolean; + error?: string; + }; + expect(result.success).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.error).toContain("simulated disk failure"); + } finally { + (fsModule.mkdir as any) = original; + } + }); +}); diff --git a/test/onboarding.test.ts b/test/onboarding.test.ts new file mode 100644 index 0000000..053085b --- /dev/null +++ b/test/onboarding.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { buildAgentOptions, getInitialAgentValues } from "../src/cli/onboarding.js"; + +describe("first-run onboarding", () => { + it("offers GitHub Copilot CLI as a native setup target", () => { + const options = buildAgentOptions(); + expect(options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: "copilot-cli", + label: expect.stringContaining("GitHub Copilot CLI"), + hint: "native plugin", + }), + ]), + ); + }); + + it("selects GitHub Copilot CLI by default when running inside Copilot CLI", () => { + expect(getInitialAgentValues({ COPILOT_CLI: "1" })).toEqual(["copilot-cli"]); + expect(getInitialAgentValues({ COPILOT_AGENT_SESSION_ID: "session" })).toEqual(["copilot-cli"]); + }); + + it("keeps Claude Code as the default outside known agent environments", () => { + expect(getInitialAgentValues({})).toEqual(["claude-code"]); + }); +}); diff --git a/test/openai-shared.test.ts b/test/openai-shared.test.ts new file mode 100644 index 0000000..e3714c6 --- /dev/null +++ b/test/openai-shared.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + buildAuthHeaders, + buildChatUrl, + buildEmbeddingUrl, + detectAzure, + normalizeBaseUrl, +} from "../src/providers/_openai-shared.js"; +import { OpenAIEmbeddingProvider } from "../src/providers/embedding/openai.js"; + +describe("_openai-shared — detectAzure", () => { + it("detects standard Azure resource hostname", () => { + expect( + detectAzure( + "https://myresource.openai.azure.com/openai/deployments/mydeploy", + ), + ).toBe(true); + }); + + it("does not flag api.openai.com", () => { + expect(detectAzure("https://api.openai.com")).toBe(false); + }); + + it("does not flag DeepSeek / SiliconFlow / Ollama / vLLM", () => { + expect(detectAzure("https://api.deepseek.com/v1")).toBe(false); + expect(detectAzure("https://api.siliconflow.cn")).toBe(false); + expect(detectAzure("http://localhost:11434/v1")).toBe(false); + expect(detectAzure("http://localhost:8000/v1")).toBe(false); + }); + + it("returns false for malformed URLs", () => { + expect(detectAzure("not-a-url")).toBe(false); + expect(detectAzure("")).toBe(false); + }); +}); + +describe("_openai-shared — buildChatUrl", () => { + it("appends /v1/chat/completions for standard OpenAI", () => { + expect(buildChatUrl("https://api.openai.com", false, "2024-08-01-preview")).toBe( + "https://api.openai.com/v1/chat/completions", + ); + }); + + it("appends /chat/completions + api-version for Azure", () => { + const url = buildChatUrl( + "https://myresource.openai.azure.com/openai/deployments/mydeploy", + true, + "2024-08-01-preview", + ); + expect(url).toBe( + "https://myresource.openai.azure.com/openai/deployments/mydeploy/chat/completions?api-version=2024-08-01-preview", + ); + }); + + it("URL-encodes the api-version", () => { + const url = buildChatUrl( + "https://r.openai.azure.com/openai/deployments/d", + true, + "preview/with/slashes", + ); + expect(url).toContain("api-version=preview%2Fwith%2Fslashes"); + }); + + it("preserves pre-existing query params on the base URL (CodeRabbit catch)", () => { + // A corporate proxy or diagnostics endpoint might already carry + // query parameters on the base URL. String-concat would have + // interpolated the route path into the query string; URL-API + // composition keeps the query intact and adds api-version + // alongside. + const url = buildChatUrl( + "https://proxy.example.com/openai/deployments/d?tenant=acme", + true, + "2024-08-01-preview", + ); + const parsed = new URL(url); + expect(parsed.pathname).toBe("/openai/deployments/d/chat/completions"); + expect(parsed.searchParams.get("tenant")).toBe("acme"); + expect(parsed.searchParams.get("api-version")).toBe("2024-08-01-preview"); + }); + + it("strips trailing slashes from base path before joining route", () => { + const url = buildChatUrl( + "https://r.openai.azure.com/openai/deployments/d/", + true, + "2024-08-01-preview", + ); + expect(new URL(url).pathname).toBe("/openai/deployments/d/chat/completions"); + }); + + it("routes through /openai/v1 when the base URL has no /deployments/ segment (Azure v1 GA)", () => { + // Azure shipped a v1 URL pattern that mirrors the OpenAI shape: + // /openai/v1/chat/completions, deployment passed in the body as + // `model`. No api-version query param. + const url = buildChatUrl( + "https://r.openai.azure.com", + true, + "2024-08-01-preview", // ignored on v1 + ); + const parsed = new URL(url); + expect(parsed.pathname).toBe("/openai/v1/chat/completions"); + expect(parsed.searchParams.get("api-version")).toBeNull(); + }); + + it("strips a trailing /openai or /openai/v1 prefix when composing v1 URLs", () => { + // Users may pre-configure OPENAI_BASE_URL with the /openai/v1 + // suffix already present. We should not double it. + const fromOpenai = buildChatUrl( + "https://r.openai.azure.com/openai", + true, + "ignored", + ); + expect(new URL(fromOpenai).pathname).toBe("/openai/v1/chat/completions"); + + const fromV1 = buildChatUrl( + "https://r.openai.azure.com/openai/v1", + true, + "ignored", + ); + expect(new URL(fromV1).pathname).toBe("/openai/v1/chat/completions"); + }); +}); + +describe("_openai-shared — buildEmbeddingUrl", () => { + it("appends /v1/embeddings for standard OpenAI", () => { + expect( + buildEmbeddingUrl("https://api.openai.com", false, "2024-08-01-preview"), + ).toBe("https://api.openai.com/v1/embeddings"); + }); + + it("appends /embeddings + api-version for Azure legacy (no /v1/ prefix)", () => { + const url = buildEmbeddingUrl( + "https://r.openai.azure.com/openai/deployments/embed-deploy", + true, + "2024-08-01-preview", + ); + expect(url).toBe( + "https://r.openai.azure.com/openai/deployments/embed-deploy/embeddings?api-version=2024-08-01-preview", + ); + }); + + it("routes through /openai/v1/embeddings on Azure v1 (no api-version)", () => { + const url = buildEmbeddingUrl( + "https://r.openai.azure.com", + true, + "2024-08-01-preview", // ignored on v1 + ); + const parsed = new URL(url); + expect(parsed.pathname).toBe("/openai/v1/embeddings"); + expect(parsed.searchParams.get("api-version")).toBeNull(); + }); +}); + +describe("_openai-shared — non-OpenAI base URLs (#628, #646)", () => { + it("does not double /v1 when base URL already ends with /v1 (DeepSeek shape, #628)", () => { + expect( + buildChatUrl("https://api.deepseek.com/v1", false, "2024-08-01-preview"), + ).toBe("https://api.deepseek.com/v1/chat/completions"); + expect( + buildEmbeddingUrl("https://api.deepseek.com/v1", false, "2024-08-01-preview"), + ).toBe("https://api.deepseek.com/v1/embeddings"); + }); + + it("does not inject /v1 when provider uses non-OpenAI version segment (Zhipu /api/paas/v4, #646)", () => { + expect( + buildChatUrl( + "https://open.bigmodel.cn/api/paas/v4", + false, + "2024-08-01-preview", + ), + ).toBe("https://open.bigmodel.cn/api/paas/v4/chat/completions"); + expect( + buildEmbeddingUrl( + "https://open.bigmodel.cn/api/paas/v4", + false, + "2024-08-01-preview", + ), + ).toBe("https://open.bigmodel.cn/api/paas/v4/embeddings"); + }); + + it("tolerates trailing slash on already-versioned base", () => { + expect( + buildChatUrl("https://api.deepseek.com/v1/", false, "2024-08-01-preview"), + ).toBe("https://api.deepseek.com/v1/chat/completions"); + }); + + it("handles localhost OpenAI-compatible servers with explicit /v1", () => { + expect( + buildChatUrl("http://localhost:11434/v1", false, "2024-08-01-preview"), + ).toBe("http://localhost:11434/v1/chat/completions"); + expect( + buildChatUrl("http://localhost:8000/v1", false, "2024-08-01-preview"), + ).toBe("http://localhost:8000/v1/chat/completions"); + }); +}); + +describe("_openai-shared — buildAuthHeaders", () => { + it("emits Authorization: Bearer for standard OpenAI", () => { + expect(buildAuthHeaders("sk-test", false)).toEqual({ + "Content-Type": "application/json", + Authorization: "Bearer sk-test", + }); + }); + + it("emits api-key header for Azure", () => { + expect(buildAuthHeaders("azure-key", true)).toEqual({ + "Content-Type": "application/json", + "api-key": "azure-key", + }); + }); +}); + +describe("_openai-shared — normalizeBaseUrl", () => { + it("returns default when no value passed", () => { + expect(normalizeBaseUrl(undefined)).toBe("https://api.openai.com"); + expect(normalizeBaseUrl("")).toBe("https://api.openai.com"); + }); + + it("strips trailing slashes", () => { + expect(normalizeBaseUrl("https://api.deepseek.com/v1///")).toBe( + "https://api.deepseek.com/v1", + ); + }); + + it("returns explicit values unchanged otherwise", () => { + expect(normalizeBaseUrl("https://api.deepseek.com/v1")).toBe( + "https://api.deepseek.com/v1", + ); + }); +}); + +// ───────────────────────────────────────────────────────────── +// OpenAIEmbeddingProvider — Azure transport (#371) +// Verifies the embedding path now uses the shared Azure helpers: +// hits /embeddings (not /v1/embeddings), includes api-version, uses +// api-key header instead of Authorization: Bearer. +// ───────────────────────────────────────────────────────────── +describe("OpenAIEmbeddingProvider — Azure auto-detection (#371)", () => { + const ORIGINAL_BASE = process.env["OPENAI_BASE_URL"]; + const ORIGINAL_VERSION = process.env["OPENAI_API_VERSION"]; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + if (ORIGINAL_BASE === undefined) delete process.env["OPENAI_BASE_URL"]; + else process.env["OPENAI_BASE_URL"] = ORIGINAL_BASE; + if (ORIGINAL_VERSION === undefined) delete process.env["OPENAI_API_VERSION"]; + else process.env["OPENAI_API_VERSION"] = ORIGINAL_VERSION; + vi.restoreAllMocks(); + }); + + it("uses Azure shape when OPENAI_BASE_URL points at *.openai.azure.com", async () => { + process.env["OPENAI_BASE_URL"] = + "https://myres.openai.azure.com/openai/deployments/embed-d"; + process.env["OPENAI_API_VERSION"] = "2024-08-01-preview"; + + let capturedUrl = ""; + let capturedHeaders = new Headers(); + vi.spyOn(globalThis, "fetch").mockImplementation( + async (url: string | URL | Request, init?: RequestInit) => { + capturedUrl = String(url); + capturedHeaders = new Headers(init?.headers); + return new Response( + JSON.stringify({ data: [{ embedding: [0.1, 0.2, 0.3] }] }), + { status: 200 }, + ); + }, + ); + + const provider = new OpenAIEmbeddingProvider("azure-key"); + await provider.embedBatch(["hello"]); + + expect(capturedUrl).toBe( + "https://myres.openai.azure.com/openai/deployments/embed-d/embeddings?api-version=2024-08-01-preview", + ); + expect(capturedHeaders.get("api-key")).toBe("azure-key"); + expect(capturedHeaders.get("Authorization")).toBeNull(); + }); + + it("uses standard shape when OPENAI_BASE_URL points at api.openai.com", async () => { + process.env["OPENAI_BASE_URL"] = "https://api.openai.com"; + + let capturedUrl = ""; + let capturedHeaders = new Headers(); + vi.spyOn(globalThis, "fetch").mockImplementation( + async (url: string | URL | Request, init?: RequestInit) => { + capturedUrl = String(url); + capturedHeaders = new Headers(init?.headers); + return new Response( + JSON.stringify({ data: [{ embedding: [0.4, 0.5, 0.6] }] }), + { status: 200 }, + ); + }, + ); + + const provider = new OpenAIEmbeddingProvider("sk-test"); + await provider.embedBatch(["hello"]); + + expect(capturedUrl).toBe("https://api.openai.com/v1/embeddings"); + expect(capturedHeaders.get("Authorization")).toBe("Bearer sk-test"); + expect(capturedHeaders.get("api-key")).toBeNull(); + }); +}); diff --git a/test/openclaw-plugin.test.ts b/test/openclaw-plugin.test.ts new file mode 100644 index 0000000..ed95d49 --- /dev/null +++ b/test/openclaw-plugin.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "vitest"; + +type Capability = { + promptBuilder?: (params: { + availableTools: Set; + }) => string[] | undefined; +}; + +type RegisterFn = (capability: Capability) => void; + +interface FakeApi { + registerMemoryCapability: RegisterFn; + on: ReturnType; + pluginConfig: Record; + logger: { warn: ReturnType }; +} + +function makeApi(overrides: Partial = {}): FakeApi { + return { + registerMemoryCapability: vi.fn(), + on: vi.fn(), + pluginConfig: { base_url: "http://localhost:3111" }, + logger: { warn: vi.fn() }, + ...overrides, + }; +} + +describe("openclaw plugin — memory capability registration (closes #286 follow-up)", () => { + it("calls api.registerMemoryCapability with a promptBuilder when the host supports it", async () => { + const mod = await import("../integrations/openclaw/plugin.mjs"); + const plugin = (mod as unknown as { default: { register(api: FakeApi): void } }).default; + const api = makeApi(); + plugin.register(api); + expect(api.registerMemoryCapability).toHaveBeenCalledTimes(1); + const capability = (api.registerMemoryCapability as ReturnType).mock.calls[0][0] as Capability; + expect(typeof capability.promptBuilder).toBe("function"); + const lines = capability.promptBuilder?.({ availableTools: new Set() }); + expect(Array.isArray(lines)).toBe(true); + expect((lines as string[]).join(" ")).toMatch(/agentmemory/i); + }); + + it("still registers hooks and tolerates older OpenClaw builds without registerMemoryCapability", async () => { + const mod = await import("../integrations/openclaw/plugin.mjs"); + const plugin = (mod as unknown as { default: { register(api: FakeApi): void } }).default; + const api = makeApi({ registerMemoryCapability: undefined as unknown as RegisterFn }); + expect(() => plugin.register(api)).not.toThrow(); + expect(api.on).toHaveBeenCalled(); + const events = (api.on as ReturnType).mock.calls.map((c) => c[0]); + expect(events).toContain("before_agent_start"); + expect(events).toContain("agent_end"); + }); + + it("promptBuilder returns lines that mention the configured base_url", async () => { + const mod = await import("../integrations/openclaw/plugin.mjs"); + const plugin = (mod as unknown as { default: { register(api: FakeApi): void } }).default; + const api = makeApi({ pluginConfig: { base_url: "http://memory.internal:9999" } }); + plugin.register(api); + const capability = (api.registerMemoryCapability as ReturnType).mock.calls[0][0] as Capability; + const lines = capability.promptBuilder?.({ availableTools: new Set() }) ?? []; + expect(lines.join("\n")).toMatch(/memory\.internal:9999/); + }); +}); diff --git a/test/opencode-auto-context.test.ts b/test/opencode-auto-context.test.ts new file mode 100644 index 0000000..3ed4959 --- /dev/null +++ b/test/opencode-auto-context.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// OpenCode plugin needs zero-config memory injection. Plugin +// already wires experimental.chat.system.transform; this PR threads +// the /session/start context through a cache so injection happens +// without a second /context fetch and is documented as the +// SessionStart-equivalent behaviour. +describe("OpenCode plugin auto-context injection (#431)", () => { + const plugin = readFileSync( + "plugin/opencode/agentmemory-capture.ts", + "utf-8", + ); + + it("captures context returned by POST /session/start", () => { + expect(plugin).toMatch(/startContextCache\s*=\s*new Map/); + expect(plugin).toMatch( + /postJson\(["']\/session\/start["']/, + ); + // Snapshot `activeSessionId` into a local before the await so the cached + // context binds to the session that opened it, not a later one. + expect(plugin).toMatch( + /const\s+sessionId\s*=\s*activeSessionId[\s\S]*?startContextCache\.set\(sessionId/, + ); + }); + + it("chat.system.transform reads cached context first, falls back to /context", () => { + expect(plugin).toMatch(/startContextCache\.get\(sid\)/); + expect(plugin).toMatch(/postJson\(["']\/context["']/); + expect(plugin).toMatch(/startContextCache\.delete\(sid\)/); + }); + + it("session.deleted clears the cache to avoid stale entries", () => { + const deletedBlock = plugin.slice(plugin.indexOf("session.deleted")); + expect(deletedBlock).toMatch(/startContextCache\.delete\(sid\)/); + }); +}); diff --git a/test/preferences.test.ts b/test/preferences.test.ts new file mode 100644 index 0000000..9989b83 --- /dev/null +++ b/test/preferences.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ORIGINAL_HOME = process.env["HOME"]; +const ORIGINAL_USERPROFILE = process.env["USERPROFILE"]; + +let sandboxHome: string; + +async function freshPrefs() { + vi.resetModules(); + return await import("../src/cli/preferences.js"); +} + +describe("cli preferences", () => { + beforeEach(() => { + sandboxHome = mkdtempSync(join(tmpdir(), "agentmemory-prefs-")); + process.env["HOME"] = sandboxHome; + process.env["USERPROFILE"] = sandboxHome; + }); + + afterEach(() => { + if (ORIGINAL_HOME === undefined) delete process.env["HOME"]; + else process.env["HOME"] = ORIGINAL_HOME; + if (ORIGINAL_USERPROFILE === undefined) delete process.env["USERPROFILE"]; + else process.env["USERPROFILE"] = ORIGINAL_USERPROFILE; + rmSync(sandboxHome, { recursive: true, force: true }); + }); + + it("returns defaults when no preferences file exists", async () => { + const { readPrefs } = await freshPrefs(); + const p = readPrefs(); + expect(p.schemaVersion).toBe(1); + expect(p.lastAgent).toBeNull(); + expect(p.lastAgents).toEqual([]); + expect(p.lastProvider).toBeNull(); + expect(p.skipSplash).toBe(false); + expect(p.skipNpxHint).toBe(false); + expect(p.firstRunAt).toBeNull(); + }); + + it("isFirstRun is true when no preferences file exists", async () => { + const { isFirstRun } = await freshPrefs(); + expect(isFirstRun()).toBe(true); + }); + + it("writePrefs persists values and merges with existing keys", async () => { + const { writePrefs, readPrefs, prefsPath } = await freshPrefs(); + writePrefs({ lastAgent: "claude-code", lastAgents: ["claude-code", "cursor"] }); + let p = readPrefs(); + expect(p.lastAgent).toBe("claude-code"); + expect(p.lastAgents).toEqual(["claude-code", "cursor"]); + expect(p.lastProvider).toBeNull(); + + writePrefs({ lastProvider: "anthropic", skipSplash: true }); + p = readPrefs(); + expect(p.lastAgent).toBe("claude-code"); + expect(p.lastProvider).toBe("anthropic"); + expect(p.skipSplash).toBe(true); + + const raw = JSON.parse(readFileSync(prefsPath(), "utf-8")); + expect(raw.schemaVersion).toBe(1); + expect(raw.lastAgents).toEqual(["claude-code", "cursor"]); + }); + + it("isFirstRun flips to false after firstRunAt is recorded", async () => { + const { writePrefs, isFirstRun } = await freshPrefs(); + writePrefs({ firstRunAt: new Date().toISOString() }); + expect(isFirstRun()).toBe(false); + }); + + it("readPrefs falls back to defaults when the file is corrupt", async () => { + const { readPrefs, prefsDir, prefsPath } = await freshPrefs(); + mkdirSync(prefsDir(), { recursive: true }); + writeFileSync(prefsPath(), "{not json", "utf-8"); + const p = readPrefs(); + expect(p.lastAgent).toBeNull(); + expect(p.schemaVersion).toBe(1); + }); + + it("readPrefs forces schemaVersion to 1 even when the file lies", async () => { + const { readPrefs, prefsDir, prefsPath } = await freshPrefs(); + mkdirSync(prefsDir(), { recursive: true }); + writeFileSync( + prefsPath(), + JSON.stringify({ schemaVersion: 99, lastAgent: "cursor" }), + "utf-8", + ); + const p = readPrefs(); + expect(p.schemaVersion).toBe(1); + expect(p.lastAgent).toBe("cursor"); + }); + + it("resetPrefs removes the file", async () => { + const { writePrefs, resetPrefs, isFirstRun, prefsPath } = await freshPrefs(); + writePrefs({ firstRunAt: new Date().toISOString() }); + expect(isFirstRun()).toBe(false); + resetPrefs(); + expect(() => readFileSync(prefsPath())).toThrow(); + expect(isFirstRun()).toBe(true); + }); +}); diff --git a/test/privacy.test.ts b/test/privacy.test.ts new file mode 100644 index 0000000..450e209 --- /dev/null +++ b/test/privacy.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from "vitest"; +import { stripPrivateData } from "../src/functions/privacy.js"; + +describe("stripPrivateData", () => { + it("strips private tags", () => { + expect(stripPrivateData("hello secret world")).toBe( + "hello [REDACTED] world", + ); + }); + + it("strips private tags case-insensitive", () => { + expect(stripPrivateData("data")).toBe("[REDACTED]"); + }); + + it("strips API keys", () => { + expect(stripPrivateData("api_key=sk-ant-1234567890abcdefghij")).toBe( + "[REDACTED_SECRET]", + ); + }); + + it("strips GitHub PATs", () => { + expect( + stripPrivateData("token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh"), + ).toBe("[REDACTED_SECRET]"); + }); + + it("strips standalone GitHub PATs", () => { + expect( + stripPrivateData("found ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij here"), + ).toBe("found [REDACTED_SECRET] here"); + }); + + it("strips Slack tokens", () => { + expect(stripPrivateData("xoxb-123456-789012-abcdef")).toBe( + "[REDACTED_SECRET]", + ); + }); + + it("strips AWS access keys", () => { + expect(stripPrivateData("key=AKIAIOSFODNN7EXAMPLE")).toBe( + "key=[REDACTED_SECRET]", + ); + }); + + it("strips JWT tokens", () => { + expect( + stripPrivateData( + "eyJhbGciOiJIUzI1.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpM", + ), + ).toBe("[REDACTED_SECRET]"); + }); + + it("strips sk- prefixed keys", () => { + expect(stripPrivateData("sk-1234567890abcdefghijklmnopqr")).toBe( + "[REDACTED_SECRET]", + ); + }); + + it("strips OpenAI project keys", () => { + expect( + stripPrivateData("sk-proj-1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"), + ).toBe("[REDACTED_SECRET]"); + }); + + it("strips GitHub fine-grained service tokens", () => { + expect( + stripPrivateData("ghs_1234567890abcdefghijklmnopqrstuvwxyzAB"), + ).toBe("[REDACTED_SECRET]"); + }); + + it("strips bearer tokens", () => { + expect( + stripPrivateData( + "Authorization: Bearer sk-proj-1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJ", + ), + ).toBe("Authorization: [REDACTED_SECRET]"); + }); + + it("handles multiple secrets in one string", () => { + const input = + "sk-abcdefghijklmnopqrstuv and ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"; + const result = stripPrivateData(input); + expect(result).not.toContain("sk-"); + expect(result).not.toContain("ghp_"); + }); + + it("does not strip short strings", () => { + expect(stripPrivateData("api_key=short")).toBe("api_key=short"); + }); + + it("returns empty string unchanged", () => { + expect(stripPrivateData("")).toBe(""); + }); + + it("handles no secrets gracefully", () => { + expect(stripPrivateData("normal text without secrets")).toBe( + "normal text without secrets", + ); + }); + + it("works correctly on consecutive calls (no regex statefulness)", () => { + const input = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZabc"; + expect(stripPrivateData(input)).toBe("[REDACTED_SECRET]"); + expect(stripPrivateData(input)).toBe("[REDACTED_SECRET]"); + expect(stripPrivateData(input)).toBe("[REDACTED_SECRET]"); + }); +}); diff --git a/test/profile.test.ts b/test/profile.test.ts new file mode 100644 index 0000000..fbc7b01 --- /dev/null +++ b/test/profile.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerProfileFunction } from "../src/functions/profile.js"; +import type { + CompressedObservation, + Session, + ProjectProfile, +} from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Profile Function", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerProfileFunction(sdk as never, kv as never); + + const session: Session = { + id: "ses_1", + project: "my-project", + cwd: "/tmp/my-project", + startedAt: "2026-02-01T00:00:00Z", + status: "completed", + observationCount: 3, + }; + await kv.set("mem:sessions", "ses_1", session); + + const obs1: CompressedObservation = { + id: "obs_1", + sessionId: "ses_1", + timestamp: "2026-02-01T10:00:00Z", + type: "file_edit", + title: "Edit auth module", + facts: [], + narrative: "Auth changes", + concepts: ["typescript", "authentication"], + files: ["/project/src/auth.ts", "/project/src/middleware.ts"], + importance: 8, + }; + const obs2: CompressedObservation = { + id: "obs_2", + sessionId: "ses_1", + timestamp: "2026-02-01T11:00:00Z", + type: "file_edit", + title: "Update database", + facts: [], + narrative: "DB changes", + concepts: ["typescript", "database"], + files: ["/project/src/db.ts"], + importance: 6, + }; + const obs3: CompressedObservation = { + id: "obs_3", + sessionId: "ses_1", + timestamp: "2026-02-01T12:00:00Z", + type: "error", + title: "Connection timeout", + facts: [], + narrative: "Error occurred", + concepts: ["error"], + files: ["/project/src/db.ts"], + importance: 4, + }; + + await kv.set("mem:obs:ses_1", "obs_1", obs1); + await kv.set("mem:obs:ses_1", "obs_2", obs2); + await kv.set("mem:obs:ses_1", "obs_3", obs3); + }); + + it("generates profile with topConcepts sorted by frequency", async () => { + const result = (await sdk.trigger("mem::profile", { + project: "my-project", + })) as { profile: ProjectProfile; cached: boolean }; + + expect(result.cached).toBe(false); + expect(result.profile.topConcepts[0].concept).toBe("typescript"); + expect(result.profile.topConcepts[0].frequency).toBe(2); + }); + + it("generates profile with topFiles sorted by frequency", async () => { + const result = (await sdk.trigger("mem::profile", { + project: "my-project", + })) as { profile: ProjectProfile }; + + expect(result.profile.topFiles[0].file).toBe("/project/src/db.ts"); + expect(result.profile.topFiles[0].frequency).toBe(2); + }); + + it("extracts conventions from file patterns", async () => { + const result = (await sdk.trigger("mem::profile", { + project: "my-project", + })) as { profile: ProjectProfile }; + + expect(result.profile.conventions).toContain("TypeScript project"); + expect(result.profile.conventions).toContain( + "Standard src/ directory structure", + ); + }); + + it("returns cached profile if fresh", async () => { + await sdk.trigger("mem::profile", { project: "my-project" }); + + const result = (await sdk.trigger("mem::profile", { + project: "my-project", + })) as { profile: ProjectProfile; cached: boolean }; + + expect(result.cached).toBe(true); + }); + + it("returns null profile for unknown project", async () => { + const result = (await sdk.trigger("mem::profile", { + project: "nonexistent", + })) as { profile: null; reason: string }; + + expect(result.profile).toBeNull(); + expect(result.reason).toBe("no_sessions"); + }); +}); diff --git a/test/query-expansion.test.ts b/test/query-expansion.test.ts new file mode 100644 index 0000000..73a5fb1 --- /dev/null +++ b/test/query-expansion.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi } from "vitest"; +import type { MemoryProvider } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, fn); + }, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +describe("QueryExpansion", () => { + it("imports without errors", async () => { + const mod = await import("../src/functions/query-expansion.js"); + expect(mod.registerQueryExpansionFunction).toBeDefined(); + expect(mod.extractEntitiesFromQuery).toBeDefined(); + }); + + it("extracts entities from capitalized words", async () => { + const { extractEntitiesFromQuery } = await import( + "../src/functions/query-expansion.js" + ); + const entities = extractEntitiesFromQuery( + 'What happened with React and the Vue migration?', + ); + expect(entities).toContain("React"); + expect(entities).toContain("Vue"); + expect(entities).not.toContain("What"); + }); + + it("extracts quoted entities", async () => { + const { extractEntitiesFromQuery } = await import( + "../src/functions/query-expansion.js" + ); + const entities = extractEntitiesFromQuery( + 'Find memories about "auth middleware" changes', + ); + expect(entities).toContain("auth middleware"); + }); + + it("expands queries via LLM", async () => { + const { registerQueryExpansionFunction } = await import( + "../src/functions/query-expansion.js" + ); + + const response = ` + + Authentication middleware modifications + JWT token validation changes + Security layer updates + + + Auth changes in the past 7 days + + + auth middleware + JWT + +`; + + const provider: MemoryProvider = { + name: "test", + compress: vi.fn().mockResolvedValue(response), + summarize: vi.fn().mockResolvedValue(response), + }; + + const sdk = mockSdk(); + registerQueryExpansionFunction(sdk as never, provider); + + const result = (await sdk.trigger("mem::expand-query", { + query: "What changed in auth?", + })) as { success: boolean; expansion: any }; + + expect(result.success).toBe(true); + expect(result.expansion.original).toBe("What changed in auth?"); + expect(result.expansion.reformulations.length).toBe(3); + expect(result.expansion.entityExtractions).toContain("auth middleware"); + expect(result.expansion.temporalConcretizations.length).toBe(1); + }); + + it("returns empty expansion on LLM failure", async () => { + const { registerQueryExpansionFunction } = await import( + "../src/functions/query-expansion.js" + ); + + const provider: MemoryProvider = { + name: "test", + compress: vi.fn().mockRejectedValue(new Error("LLM down")), + summarize: vi.fn().mockRejectedValue(new Error("LLM down")), + }; + + const sdk = mockSdk(); + registerQueryExpansionFunction(sdk as never, provider); + + const result = (await sdk.trigger("mem::expand-query", { + query: "test query", + })) as { success: boolean; expansion: any }; + + expect(result.success).toBe(true); + expect(result.expansion.original).toBe("test query"); + expect(result.expansion.reformulations).toEqual([]); + }); + + it("respects maxReformulations limit", async () => { + const { registerQueryExpansionFunction } = await import( + "../src/functions/query-expansion.js" + ); + + const response = ` + + Query A + Query B + Query C + Query D + Query E + Query F + + + +`; + + const provider: MemoryProvider = { + name: "test", + compress: vi.fn().mockResolvedValue(response), + summarize: vi.fn().mockResolvedValue(response), + }; + + const sdk = mockSdk(); + registerQueryExpansionFunction(sdk as never, provider); + + const result = (await sdk.trigger("mem::expand-query", { + query: "test", + maxReformulations: 3, + })) as { success: boolean; expansion: any }; + + expect(result.expansion.reformulations.length).toBe(3); + }); +}); diff --git a/test/reflect.test.ts b/test/reflect.test.ts new file mode 100644 index 0000000..7ea163d --- /dev/null +++ b/test/reflect.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerReflectFunctions } from "../src/functions/reflect.js"; +import type { Insight, GraphNode, GraphEdge, SemanticMemory, Lesson, Crystal } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeConceptNode(name: string): GraphNode { + return { + id: `node_${name}`, + type: "concept", + name, + properties: {}, + sourceObservationIds: [], + createdAt: "2026-04-01T00:00:00Z", + }; +} + +function makeEdge(src: string, tgt: string): GraphEdge { + return { + id: `edge_${src}_${tgt}`, + type: "related_to", + sourceNodeId: `node_${src}`, + targetNodeId: `node_${tgt}`, + weight: 1, + sourceObservationIds: [], + createdAt: "2026-04-01T00:00:00Z", + }; +} + +function makeSemantic(fact: string, id?: string): SemanticMemory { + return { + id: id || `sem_${fact.slice(0, 8)}`, + fact, + confidence: 0.8, + sourceSessionIds: [], + sourceMemoryIds: [], + accessCount: 1, + lastAccessedAt: "2026-04-01T00:00:00Z", + strength: 0.8, + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-04-01T00:00:00Z", + }; +} + +function makeLesson(content: string, tags: string[]): Lesson { + return { + id: `lsn_${content.slice(0, 8)}`, + content, + context: "", + confidence: 0.7, + reinforcements: 0, + source: "manual", + sourceIds: [], + tags, + createdAt: "2026-04-01T00:00:00Z", + updatedAt: "2026-04-01T00:00:00Z", + decayRate: 0.05, + }; +} + +function makeCrystal(narrative: string, lessons: string[]): Crystal { + return { + id: `crys_${narrative.slice(0, 8)}`, + narrative, + keyOutcomes: [], + filesAffected: [], + lessons, + sourceActionIds: [], + createdAt: "2026-04-01T00:00:00Z", + }; +} + +const XML_RESPONSE = ` + +Security requires layered protection: input validation, safe APIs, and deny-lists together. + + +Focus test effort on system boundaries where trust transitions occur. + +`; + +describe("Reflect", () => { + let sdk: ReturnType; + let kv: ReturnType; + let provider: { name: string; compress: ReturnType; summarize: ReturnType }; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + provider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn().mockResolvedValue(XML_RESPONSE), + }; + registerReflectFunctions(sdk as never, kv as never, provider as never); + }); + + describe("mem::reflect", () => { + it("returns empty when no graph nodes or memories exist", async () => { + const result = (await sdk.trigger("mem::reflect", {})) as { + success: boolean; + newInsights: number; + clustersProcessed: number; + }; + + expect(result.success).toBe(true); + expect(result.newInsights).toBe(0); + expect(result.clustersProcessed).toBe(0); + }); + + it("synthesizes insights from graph concept clusters", async () => { + await kv.set("mem:graph:nodes", "node_security", makeConceptNode("security")); + await kv.set("mem:graph:nodes", "node_validation", makeConceptNode("validation")); + await kv.set("mem:graph:nodes", "node_testing", makeConceptNode("testing")); + await kv.set("mem:graph:edges", "edge_1", makeEdge("security", "validation")); + await kv.set("mem:graph:edges", "edge_2", makeEdge("security", "testing")); + + await kv.set("mem:semantic", "sem_1", makeSemantic("Always validate security inputs")); + await kv.set("mem:semantic", "sem_2", makeSemantic("Testing improves security coverage")); + await kv.set("mem:semantic", "sem_3", makeSemantic("Validation prevents injection attacks")); + await kv.set("mem:lessons", "lsn_1", makeLesson("Use execFile for security", ["security"])); + + const result = (await sdk.trigger("mem::reflect", {})) as { + success: boolean; + newInsights: number; + }; + + expect(result.success).toBe(true); + expect(result.newInsights).toBe(2); + expect(provider.summarize).toHaveBeenCalled(); + + const insights = await kv.list("mem:insights"); + expect(insights.length).toBe(2); + expect(insights[0].title).toBeTruthy(); + expect(insights[0].sourceConceptCluster.length).toBeGreaterThan(0); + }); + + it("skips clusters with fewer than 3 supporting items", async () => { + await kv.set("mem:graph:nodes", "node_sparse", makeConceptNode("sparse")); + await kv.set("mem:graph:nodes", "node_topic", makeConceptNode("topic")); + await kv.set("mem:graph:edges", "edge_1", makeEdge("sparse", "topic")); + await kv.set("mem:semantic", "sem_1", makeSemantic("One sparse fact")); + + const result = (await sdk.trigger("mem::reflect", {})) as { + clustersSkipped: number; + newInsights: number; + }; + + expect(result.clustersSkipped).toBe(1); + expect(result.newInsights).toBe(0); + expect(provider.summarize).not.toHaveBeenCalled(); + }); + + it("deduplicates insights by fingerprint", async () => { + await kv.set("mem:graph:nodes", "node_security", makeConceptNode("security")); + await kv.set("mem:graph:nodes", "node_validation", makeConceptNode("validation")); + await kv.set("mem:graph:edges", "edge_1", makeEdge("security", "validation")); + await kv.set("mem:semantic", "sem_1", makeSemantic("Always validate security inputs")); + await kv.set("mem:semantic", "sem_2", makeSemantic("Testing improves security coverage")); + await kv.set("mem:semantic", "sem_3", makeSemantic("Validation prevents injection")); + + await sdk.trigger("mem::reflect", {}); + const first = await kv.list("mem:insights"); + expect(first.length).toBe(2); + + const result = (await sdk.trigger("mem::reflect", {})) as { + reinforced: number; + newInsights: number; + }; + + expect(result.reinforced).toBe(2); + expect(result.newInsights).toBe(0); + + const after = await kv.list("mem:insights"); + expect(after.length).toBe(2); + expect(after[0].reinforcements).toBe(1); + }); + + it("falls back to Jaccard grouping when graph is empty", async () => { + await kv.set("mem:semantic", "sem_1", makeSemantic("security validation is important")); + await kv.set("mem:semantic", "sem_2", makeSemantic("security testing prevents bugs")); + await kv.set("mem:semantic", "sem_3", makeSemantic("validation testing framework")); + await kv.set("mem:lessons", "lsn_1", makeLesson("Use security headers", ["security", "validation"])); + + const result = (await sdk.trigger("mem::reflect", {})) as { + success: boolean; + usedFallback: boolean; + }; + + expect(result.success).toBe(true); + expect(result.usedFallback).toBe(true); + }); + + it("handles LLM failure gracefully", async () => { + provider.summarize.mockRejectedValue(new Error("LLM timeout")); + + await kv.set("mem:graph:nodes", "node_a", makeConceptNode("concept_a")); + await kv.set("mem:graph:nodes", "node_b", makeConceptNode("concept_b")); + await kv.set("mem:graph:edges", "edge_1", makeEdge("concept_a", "concept_b")); + await kv.set("mem:semantic", "sem_1", makeSemantic("fact about concept_a")); + await kv.set("mem:semantic", "sem_2", makeSemantic("fact about concept_b")); + await kv.set("mem:semantic", "sem_3", makeSemantic("concept_a and concept_b together")); + + const result = (await sdk.trigger("mem::reflect", {})) as { + success: boolean; + newInsights: number; + }; + + expect(result.success).toBe(true); + expect(result.newInsights).toBe(0); + }); + }); + + describe("mem::insight-list", () => { + beforeEach(async () => { + const now = new Date().toISOString(); + await kv.set("mem:insights", "ins_1", { + id: "ins_1", title: "Insight A", content: "Content A", confidence: 0.9, + reinforcements: 2, sourceConceptCluster: ["security"], sourceMemoryIds: [], + sourceLessonIds: [], sourceCrystalIds: [], project: "/app", + tags: ["security"], createdAt: now, updatedAt: now, decayRate: 0.05, + }); + await kv.set("mem:insights", "ins_2", { + id: "ins_2", title: "Insight B", content: "Content B", confidence: 0.4, + reinforcements: 0, sourceConceptCluster: ["testing"], sourceMemoryIds: [], + sourceLessonIds: [], sourceCrystalIds: [], project: "/other", + tags: ["testing"], createdAt: now, updatedAt: now, decayRate: 0.05, + }); + }); + + it("lists all non-deleted insights sorted by confidence", async () => { + const result = (await sdk.trigger("mem::insight-list", {})) as { insights: Insight[] }; + expect(result.insights.length).toBe(2); + expect(result.insights[0].confidence).toBe(0.9); + }); + + it("filters by project", async () => { + const result = (await sdk.trigger("mem::insight-list", { project: "/app" })) as { insights: Insight[] }; + expect(result.insights.length).toBe(1); + }); + + it("filters by minConfidence", async () => { + const result = (await sdk.trigger("mem::insight-list", { minConfidence: 0.5 })) as { insights: Insight[] }; + expect(result.insights.length).toBe(1); + }); + }); + + describe("mem::insight-search", () => { + beforeEach(async () => { + const now = new Date().toISOString(); + await kv.set("mem:insights", "ins_1", { + id: "ins_1", title: "Defense in Depth", content: "Security requires layered protection", + confidence: 0.85, reinforcements: 1, sourceConceptCluster: ["security"], + sourceMemoryIds: [], sourceLessonIds: [], sourceCrystalIds: [], + tags: ["security"], createdAt: now, updatedAt: now, decayRate: 0.05, + }); + }); + + it("finds insights matching query", async () => { + const result = (await sdk.trigger("mem::insight-search", { + query: "security layered protection", + })) as { insights: Array }; + + expect(result.insights.length).toBe(1); + expect(result.insights[0].title).toBe("Defense in Depth"); + }); + + it("rejects empty query", async () => { + const result = (await sdk.trigger("mem::insight-search", { query: "" })) as { success: boolean }; + expect(result.success).toBe(false); + }); + }); + + describe("mem::insight-decay-sweep", () => { + it("decays old insights incrementally", async () => { + await kv.set("mem:insights", "ins_old", { + id: "ins_old", title: "Old", content: "Old insight", confidence: 0.8, + reinforcements: 1, sourceConceptCluster: [], sourceMemoryIds: [], + sourceLessonIds: [], sourceCrystalIds: [], tags: [], + createdAt: new Date(Date.now() - 21 * 86400000).toISOString(), + updatedAt: new Date(Date.now() - 21 * 86400000).toISOString(), + decayRate: 0.05, + }); + + const result = (await sdk.trigger("mem::insight-decay-sweep", {})) as { decayed: number }; + expect(result.decayed).toBe(1); + + const after = await kv.get("mem:insights", "ins_old"); + expect(after!.confidence).toBeLessThan(0.8); + expect(after!.lastDecayedAt).toBeDefined(); + }); + + it("soft-deletes low-confidence unreinforced insights", async () => { + await kv.set("mem:insights", "ins_weak", { + id: "ins_weak", title: "Weak", content: "Weak insight", confidence: 0.12, + reinforcements: 0, sourceConceptCluster: [], sourceMemoryIds: [], + sourceLessonIds: [], sourceCrystalIds: [], tags: [], + createdAt: new Date(Date.now() - 21 * 86400000).toISOString(), + updatedAt: new Date(Date.now() - 21 * 86400000).toISOString(), + decayRate: 0.05, + }); + + const result = (await sdk.trigger("mem::insight-decay-sweep", {})) as { softDeleted: number }; + expect(result.softDeleted).toBe(1); + + const after = await kv.get("mem:insights", "ins_weak"); + expect(after!.deleted).toBe(true); + }); + }); +}); diff --git a/test/relations.test.ts b/test/relations.test.ts new file mode 100644 index 0000000..bf6025e --- /dev/null +++ b/test/relations.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerRelationsFunction } from "../src/functions/relations.js"; +import type { Memory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeMemory(overrides: Partial = {}): Memory { + return { + id: "mem_1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "pattern", + title: "Test memory", + content: "This is a test memory", + concepts: ["test"], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + ...overrides, + }; +} + +describe("Relations Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerRelationsFunction(sdk as never, kv as never); + }); + + describe("mem::relate", () => { + it("creates a relation between two memories", async () => { + const mem1 = makeMemory({ id: "mem_1" }); + const mem2 = makeMemory({ id: "mem_2" }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + + const result = await sdk.trigger("mem::relate", { + sourceId: "mem_1", + targetId: "mem_2", + type: "related", + }); + + expect((result as { success: boolean }).success).toBe(true); + + const updated1 = await kv.get("mem:memories", "mem_1"); + const updated2 = await kv.get("mem:memories", "mem_2"); + expect(updated1!.relatedIds).toContain("mem_2"); + expect(updated2!.relatedIds).toContain("mem_1"); + }); + + it("returns error when source memory not found", async () => { + const mem2 = makeMemory({ id: "mem_2" }); + await kv.set("mem:memories", "mem_2", mem2); + + const result = await sdk.trigger("mem::relate", { + sourceId: "mem_missing", + targetId: "mem_2", + type: "related", + }); + + expect((result as { success: boolean }).success).toBe(false); + }); + + it("does not duplicate relatedIds on repeated calls", async () => { + const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2"] }); + const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1"] }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + + await sdk.trigger("mem::relate", { + sourceId: "mem_1", + targetId: "mem_2", + type: "related", + }); + + const updated1 = await kv.get("mem:memories", "mem_1"); + expect(updated1!.relatedIds!.filter((id) => id === "mem_2").length).toBe(1); + }); + }); + + describe("mem::evolve", () => { + it("marks old memory as not latest and creates new version", async () => { + const original = makeMemory({ id: "mem_old", version: 1 }); + await kv.set("mem:memories", "mem_old", original); + + const result = (await sdk.trigger("mem::evolve", { + memoryId: "mem_old", + newContent: "Updated content", + newTitle: "Updated title", + })) as { success: boolean; memory: Memory; previousId: string }; + + expect(result.success).toBe(true); + expect(result.memory.version).toBe(2); + expect(result.memory.content).toBe("Updated content"); + expect(result.memory.title).toBe("Updated title"); + expect(result.memory.parentId).toBe("mem_old"); + expect(result.memory.isLatest).toBe(true); + + const old = await kv.get("mem:memories", "mem_old"); + expect(old!.isLatest).toBe(false); + }); + + it("returns error when memory not found", async () => { + const result = await sdk.trigger("mem::evolve", { + memoryId: "mem_missing", + newContent: "Updated content", + }); + + expect((result as { success: boolean }).success).toBe(false); + }); + }); + + describe("mem::get-related", () => { + it("retrieves related memories within 1 hop", async () => { + const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2"] }); + const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1", "mem_3"] }); + const mem3 = makeMemory({ id: "mem_3", relatedIds: ["mem_2"] }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + await kv.set("mem:memories", "mem_3", mem3); + + const result = (await sdk.trigger("mem::get-related", { + memoryId: "mem_1", + maxHops: 1, + })) as { results: Array<{ memory: Memory; hop: number }> }; + + expect(result.results.length).toBe(1); + expect(result.results[0].memory.id).toBe("mem_2"); + expect(result.results[0].hop).toBe(1); + }); + + it("retrieves related memories within 2 hops", async () => { + const mem1 = makeMemory({ id: "mem_1", relatedIds: ["mem_2"] }); + const mem2 = makeMemory({ id: "mem_2", relatedIds: ["mem_1", "mem_3"] }); + const mem3 = makeMemory({ id: "mem_3", relatedIds: ["mem_2"] }); + await kv.set("mem:memories", "mem_1", mem1); + await kv.set("mem:memories", "mem_2", mem2); + await kv.set("mem:memories", "mem_3", mem3); + + const result = (await sdk.trigger("mem::get-related", { + memoryId: "mem_1", + maxHops: 2, + })) as { results: Array<{ memory: Memory; hop: number }> }; + + expect(result.results.length).toBe(2); + const ids = result.results.map((r) => r.memory.id); + expect(ids).toContain("mem_2"); + expect(ids).toContain("mem_3"); + }); + }); +}); diff --git a/test/remember-bm25-index.test.ts b/test/remember-bm25-index.test.ts new file mode 100644 index 0000000..77ee8d1 --- /dev/null +++ b/test/remember-bm25-index.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { SearchIndex } from "../src/state/search-index.js"; +import type { CompressedObservation, Memory } from "../src/types.js"; + +// Mirrors the helper used by remember.ts and rebuildIndex(). Kept inline +// here rather than exporting from src/ so the test asserts the contract, +// not the implementation. +function memoryAsIndexable(memory: Memory): CompressedObservation { + return { + id: memory.id, + sessionId: memory.sessionIds[0] ?? "memory", + timestamp: memory.createdAt, + type: "decision", + title: memory.title, + facts: [memory.content], + narrative: memory.content, + concepts: memory.concepts, + files: memory.files, + importance: memory.strength, + }; +} + +function makeMemory(overrides: Partial = {}): Memory { + return { + id: "mem_test_001", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "fact", + title: "BM25 test memory", + content: "BM25 search returns this memory by keyword match", + concepts: ["bm25", "search", "test"], + files: [], + sessionIds: [], + strength: 7, + version: 1, + isLatest: true, + ...overrides, + }; +} + +describe("SearchIndex.has()", () => { + it("returns false for unknown ids", () => { + expect(new SearchIndex().has("mem_unknown")).toBe(false); + }); + + it("returns true after add()", () => { + const idx = new SearchIndex(); + idx.add(memoryAsIndexable(makeMemory())); + expect(idx.has("mem_test_001")).toBe(true); + }); +}); + +describe("memory indexing into SearchIndex (closes #257)", () => { + it("makes a saved memory findable by keyword search", () => { + const idx = new SearchIndex(); + idx.add(memoryAsIndexable(makeMemory({ + id: "mem_user_001", + title: "JWT middleware uses jose for Edge compatibility", + content: "Chose jose over jsonwebtoken because Cloudflare Workers don't ship Node crypto", + concepts: ["auth", "jose", "edge"], + }))); + + const hits = idx.search("jose middleware", 5); + expect(hits).toHaveLength(1); + expect(hits[0].obsId).toBe("mem_user_001"); + }); + + it("returns the memory when the issue's reproduction query is run", () => { + // From issue #257: user saved a memory containing 'BM25 test' + // keywords and the search returned empty — recall failure. + const idx = new SearchIndex(); + idx.add(memoryAsIndexable(makeMemory({ + id: "mem_moy3u6ua_8c6962b668e7", + title: "BM25 test", + content: "Confirmed BM25 indexing works for memories saved via memory_save", + concepts: [], + }))); + + const hits = idx.search("BM25 test", 5); + expect(hits.length).toBeGreaterThan(0); + expect(hits[0].obsId).toBe("mem_moy3u6ua_8c6962b668e7"); + }); + + it("matches concepts as well as title and content", () => { + const idx = new SearchIndex(); + idx.add(memoryAsIndexable(makeMemory({ + id: "mem_concept_001", + title: "Generic title", + content: "Generic content", + concepts: ["unique-concept-marker"], + }))); + + const hits = idx.search("unique-concept-marker", 5); + expect(hits.length).toBeGreaterThan(0); + expect(hits[0].obsId).toBe("mem_concept_001"); + }); +}); diff --git a/test/remember-forget-audit.test.ts b/test/remember-forget-audit.test.ts new file mode 100644 index 0000000..7d17b54 --- /dev/null +++ b/test/remember-forget-audit.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/keyed-mutex.js", () => ({ + withKeyedLock: (_key: string, fn: () => Promise) => fn(), +})); + +import { registerRememberFunction } from "../src/functions/remember.js"; +import { + getSearchIndex, + setIndexPersistence, +} from "../src/functions/search.js"; +import { memoryToObservation } from "../src/state/memory-utils.js"; +import type { Memory } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (input: { function_id: string; payload: unknown }) => { + const fn = functions.get(input.function_id); + if (!fn) throw new Error(`unknown fn ${input.function_id}`); + return fn(input.payload); + }, + }; +} + +describe("mem::forget audit coverage (issue #125)", () => { + it("emits a single audit row when a memory is forgotten", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + await kv.set("mem:memories", "mem_a", { id: "mem_a", content: "x" }); + + const result = await sdk.trigger({ + function_id: "mem::forget", + payload: { memoryId: "mem_a" }, + }); + expect((result as { deleted: number }).deleted).toBe(1); + + const auditRows = await kv.list<{ + operation: string; + functionId: string; + targetIds: string[]; + details: Record; + }>("mem:audit"); + expect(auditRows).toHaveLength(1); + const [row] = auditRows; + expect(row.operation).toBe("forget"); + expect(row.functionId).toBe("mem::forget"); + expect(row.targetIds).toEqual(["mem_a"]); + expect(row.details.memoriesDeleted).toBe(1); + expect(row.details.observationsDeleted).toBe(0); + expect(row.details.sessionDeleted).toBe(false); + }); + + it("emits one batched audit row when an entire session is forgotten", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + await kv.set("mem:sessions", "sess_1", { id: "sess_1" }); + await kv.set("mem:summaries", "sess_1", { id: "sess_1" }); + await kv.set("mem:obs:sess_1", "obs_a", { id: "obs_a" }); + await kv.set("mem:obs:sess_1", "obs_b", { id: "obs_b" }); + + await sdk.trigger({ + function_id: "mem::forget", + payload: { sessionId: "sess_1" }, + }); + + const auditRows = await kv.list<{ + targetIds: string[]; + details: Record; + }>("mem:audit"); + expect(auditRows).toHaveLength(1); + const [row] = auditRows; + expect([...row.targetIds].sort()).toEqual(["obs_a", "obs_b"]); + expect(row.details.memoriesDeleted).toBe(0); + expect(row.details.observationsDeleted).toBe(2); + expect(row.details.sessionDeleted).toBe(true); + expect(row.details.deleted).toBe(4); + }); + + it("does not emit an audit row when nothing is deleted", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + await sdk.trigger({ + function_id: "mem::forget", + payload: { sessionId: undefined, memoryId: undefined }, + }); + + const auditRows = await kv.list("mem:audit"); + expect(auditRows).toHaveLength(0); + }); +}); + +// Delete paths must tear down the BM25 index entry and synchronously +// flush the persisted snapshot. Without this, a deleted memory keeps +// occupying limit-capped search result slots, and an in-memory remove +// would be lost if the process exits before the debounced save fires. +describe("mem::forget search-index cleanup", () => { + function makeMemory(id: string): Memory { + return { + id, + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type: "fact", + title: `title ${id}`, + content: `content ${id}`, + concepts: [], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + }; + } + + beforeEach(() => { + getSearchIndex().clear(); + setIndexPersistence(null); + }); + + afterEach(() => { + setIndexPersistence(null); + }); + + it("removes a forgotten memory from the BM25 index", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const mem = makeMemory("mem_a"); + await kv.set("mem:memories", mem.id, mem); + getSearchIndex().add(memoryToObservation(mem)); + expect(getSearchIndex().has("mem_a")).toBe(true); + + await sdk.trigger({ + function_id: "mem::forget", + payload: { memoryId: "mem_a" }, + }); + + expect(getSearchIndex().has("mem_a")).toBe(false); + }); + + it("removes forgotten observations from the BM25 index", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + await kv.set("mem:obs:ses_1", "obs_a", { id: "obs_a" }); + await kv.set("mem:obs:ses_1", "obs_b", { id: "obs_b" }); + getSearchIndex().add(memoryToObservation(makeMemory("obs_a"))); + getSearchIndex().add(memoryToObservation(makeMemory("obs_b"))); + + await sdk.trigger({ + function_id: "mem::forget", + payload: { sessionId: "ses_1", observationIds: ["obs_a"] }, + }); + + expect(getSearchIndex().has("obs_a")).toBe(false); + expect(getSearchIndex().has("obs_b")).toBe(true); + }); + + it("flushes persistence immediately when a memory is forgotten", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) }; + setIndexPersistence(persistence); + + await kv.set("mem:memories", "mem_a", makeMemory("mem_a")); + + await sdk.trigger({ + function_id: "mem::forget", + payload: { memoryId: "mem_a" }, + }); + + expect(persistence.save).toHaveBeenCalled(); + }); +}); diff --git a/test/remember-project-scope.test.ts b/test/remember-project-scope.test.ts new file mode 100644 index 0000000..bda699f --- /dev/null +++ b/test/remember-project-scope.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/keyed-mutex.js", () => ({ + withKeyedLock: (_key: string, fn: () => Promise) => fn(), +})); + +vi.mock("iii-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + TriggerAction: { + ...actual.TriggerAction, + Void: vi.fn(() => ({ type: "void" })), + }, + }; +}); + +import { vi } from "vitest"; +import { registerRememberFunction } from "../src/functions/remember.js"; +import { getSearchIndex, setIndexPersistence } from "../src/functions/search.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (input: { function_id: string; payload: unknown; action?: unknown }) => { + const fn = functions.get(input.function_id); + if (!fn) return {}; + return fn(input.payload); + }, + }; +} + +describe("mem::remember — project field stamping", () => { + beforeEach(() => { + getSearchIndex().clear(); + setIndexPersistence(null); + }); + + afterEach(() => { + setIndexPersistence(null); + }); + + it("persists project on the saved memory when provided", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "express-jwt requires trimmed Bearer token", + type: "bug", + files: ["src/middleware/auth.ts"], + project: "api", + }, + }) as { success: boolean; memory: { id: string; project?: string } }; + + expect(result.success).toBe(true); + expect(result.memory.project).toBe("api"); + + const stored = await kv.get<{ project?: string }>("mem:memories", result.memory.id); + expect(stored?.project).toBe("api"); + }); + + it("leaves project undefined when not provided (backward-compat)", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "some unscoped memory" }, + }) as { success: boolean; memory: { id: string; project?: string } }; + + expect(result.success).toBe(true); + expect(result.memory.project).toBeUndefined(); + }); + + it("trims whitespace from the project value", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "padded project name", project: " api " }, + }) as { success: boolean; memory: { project?: string } }; + + expect(result.memory.project).toBe("api"); + }); + + it("treats a blank project string the same as no project", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "blank project string", project: " " }, + }) as { success: boolean; memory: { project?: string } }; + + expect(result.memory.project).toBeUndefined(); + }); +}); + +describe("mem::remember — cross-project dedup isolation", () => { + beforeEach(() => { + getSearchIndex().clear(); + setIndexPersistence(null); + }); + + afterEach(() => { + setIndexPersistence(null); + }); + + it("does not supersede a memory from a different project even when content is similar", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + // Save a memory in project "api" + const first = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "always use express-jwt middleware for token validation in this project", + type: "pattern", + project: "api", + }, + }) as { memory: { id: string; isLatest: boolean; project?: string } }; + + // Save a near-identical memory in project "web" — should NOT supersede the api one + const second = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "always use express-jwt middleware for token validation in this project", + type: "pattern", + project: "web", + }, + }) as { memory: { id: string; supersedes: string[]; project?: string } }; + + expect(second.memory.project).toBe("web"); + expect(second.memory.supersedes).toHaveLength(0); + + // The api memory must still be isLatest + const apiMemory = await kv.get<{ isLatest: boolean }>("mem:memories", first.memory.id); + expect(apiMemory?.isLatest).toBe(true); + }); + + it("still supersedes within the same project when content is similar", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const first = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "always use express-jwt middleware for token validation in this project", + type: "pattern", + project: "api", + }, + }) as { memory: { id: string } }; + + const second = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "always use express-jwt middleware for token validation in this project", + type: "pattern", + project: "api", + }, + }) as { memory: { supersedes: string[] } }; + + expect(second.memory.supersedes).toContain(first.memory.id); + + const original = await kv.get<{ isLatest: boolean }>("mem:memories", first.memory.id); + expect(original?.isLatest).toBe(false); + }); + + it("allows an unscoped memory to be superseded by a scoped one (legacy compat)", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + // Existing legacy memory with no project + const legacy = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "always use express-jwt middleware for token validation in this project", + type: "pattern", + }, + }) as { memory: { id: string } }; + + // New scoped memory — should supersede the legacy unscoped one + const scoped = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "always use express-jwt middleware for token validation in this project", + type: "pattern", + project: "api", + }, + }) as { memory: { supersedes: string[] } }; + + expect(scoped.memory.supersedes).toContain(legacy.memory.id); + }); + + it("allows a scoped memory to be superseded by an unscoped one (legacy compat)", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const scoped = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "always use express-jwt middleware for token validation in this project", + type: "pattern", + project: "api", + }, + }) as { memory: { id: string } }; + + // Unscoped write — should still supersede since one side has no project + const unscoped = await sdk.trigger({ + function_id: "mem::remember", + payload: { + content: "always use express-jwt middleware for token validation in this project", + type: "pattern", + }, + }) as { memory: { supersedes: string[] } }; + + expect(unscoped.memory.supersedes).toContain(scoped.memory.id); + }); +}); diff --git a/test/replay-import-key.test.ts b/test/replay-import-key.test.ts new file mode 100644 index 0000000..9a4c646 --- /dev/null +++ b/test/replay-import-key.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerReplayFunctions } from "../src/functions/replay.js"; +import { KV } from "../src/state/schema.js"; + +function mockKV() { + const store = new Map>(); + const setCalls: Array<{ scope: string; key: string | undefined; value: any }> = []; + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, value: T): Promise => { + setCalls.push({ scope, key, value }); + if (!store.has(scope)) store.set(scope, new Map()); + // Mirror the engine: a state::set with key=undefined fails. We + // surface this via setCalls so the test can assert key !== undefined. + if (key === undefined) { + throw new Error("missing field `key`"); + } + store.get(scope)!.set(key, value); + return value; + }, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => + Array.from(store.get(scope)?.values() ?? []) as T[], + getSetCalls: () => setCalls, + }; +} + +function mockSdk(kv: ReturnType) { + const fns = new Map(); + return { + registerFunction: (id: string, handler: Function) => fns.set(id, handler), + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload?: unknown }, + data?: unknown, + ) => { + const id = + typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = + typeof idOrInput === "string" ? data : (idOrInput as any).payload; + const fn = fns.get(id); + if (!fn) return { success: true }; + return fn(payload); + }, + _kv: kv, + } as any; +} + +describe("import-jsonl re-key on parsed.sessionId (#775)", () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), "replay-import-key-")); + }); + + function writeFixture(sessionId: string, ts = "2026-04-17T10:00:00.000Z") { + const dir = join(tmpRoot, "proj"); + rmSync(dir, { recursive: true, force: true }); + require("node:fs").mkdirSync(dir, { recursive: true }); + const lines = [ + JSON.stringify({ + type: "user", + uuid: "u1", + sessionId, + timestamp: ts, + cwd: tmpRoot, + message: { + role: "user", + content: [{ type: "text", text: "hello" }], + }, + }), + JSON.stringify({ + type: "assistant", + uuid: "a1", + sessionId, + timestamp: ts, + message: { + role: "assistant", + content: [{ type: "text", text: "world" }], + }, + }), + ]; + writeFileSync(join(dir, `${sessionId}.jsonl`), lines.join("\n") + "\n"); + } + + it("re-imports a session whose stored row is missing the `id` field without aborting the batch", async () => { + writeFixture("sess-no-id"); + const kv = mockKV(); + const sdk = mockSdk(kv); + registerReplayFunctions(sdk, kv as never); + + // Seed an existing session row that is MISSING `id` — the + // pre-fix code would re-key on `existing.id` (undefined) and + // throw `missing field \`key\``, aborting the whole import. + await kv.set(KV.sessions, "sess-no-id", { + project: "proj", + cwd: tmpRoot, + startedAt: "2026-04-17T09:00:00Z", + endedAt: "2026-04-17T09:30:00Z", + status: "completed", + observationCount: 2, + tags: [], + }); + + const result = (await sdk.trigger("mem::replay::import-jsonl", { + path: tmpRoot, + })) as { success: boolean; imported?: number; error?: string }; + + expect(result.success).toBe(true); + expect(result.imported).toBe(1); + + const undefinedKeyWrites = kv + .getSetCalls() + .filter((c) => c.scope === KV.sessions && c.key === undefined); + expect(undefinedKeyWrites.length).toBe(0); + + const sessionWrites = kv + .getSetCalls() + .filter((c) => c.scope === KV.sessions && c.key === "sess-no-id"); + expect(sessionWrites.length).toBeGreaterThan(0); + // The handler also backfills the missing id field so future reads + // are well-formed. + expect((sessionWrites.at(-1)!.value as any).id).toBe("sess-no-id"); + }); + + it("fresh import (no existing row) still writes session keyed by parsed.sessionId", async () => { + writeFixture("sess-fresh"); + const kv = mockKV(); + const sdk = mockSdk(kv); + registerReplayFunctions(sdk, kv as never); + + const result = (await sdk.trigger("mem::replay::import-jsonl", { + path: tmpRoot, + })) as { success: boolean; imported?: number }; + + expect(result.success).toBe(true); + expect(result.imported).toBe(1); + const sessionWrites = kv + .getSetCalls() + .filter((c) => c.scope === KV.sessions && c.key === "sess-fresh"); + expect(sessionWrites.length).toBe(1); + }); +}); diff --git a/test/replay-sensitive.test.ts b/test/replay-sensitive.test.ts new file mode 100644 index 0000000..6550dc7 --- /dev/null +++ b/test/replay-sensitive.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { isSensitive } from "../src/functions/replay.js"; + +describe("isSensitive path guard", () => { + it("blocks .env and common secret filenames", () => { + expect(isSensitive("/Users/x/project/.env")).toBe(true); + expect(isSensitive("/Users/x/project/.env.local")).toBe(true); + expect(isSensitive("/tmp/credentials.json")).toBe(true); + expect(isSensitive("/home/alice/.ssh/id_rsa")).toBe(true); + expect(isSensitive("/srv/app/secret.key")).toBe(true); + expect(isSensitive("/srv/app/access_token.txt")).toBe(true); + expect(isSensitive("/srv/app/private_key.pem")).toBe(true); + }); + + it("does not false-positive on project names containing substrings", () => { + expect(isSensitive("/Users/dev/jsonwebtoken-demo/transcript.jsonl")).toBe(false); + expect(isSensitive("/repos/secrethandshake-lib/a.jsonl")).toBe(false); + expect(isSensitive("/opt/tokeniser/out.jsonl")).toBe(false); + expect(isSensitive("/Users/alice/.claude/projects/myapp/abc.jsonl")).toBe(false); + }); +}); diff --git a/test/replay.test.ts b/test/replay.test.ts new file mode 100644 index 0000000..f5dfb9a --- /dev/null +++ b/test/replay.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parseJsonlText } from "../src/replay/jsonl-parser.js"; +import { projectTimeline } from "../src/replay/timeline.js"; + +const fx = (name: string) => + readFileSync(join(__dirname, "fixtures/jsonl", name), "utf-8"); + +describe("parseJsonlText", () => { + it("parses basic user/assistant exchange", () => { + const out = parseJsonlText(fx("basic.jsonl")); + expect(out.sessionId).toBe("sess-basic"); + expect(out.project).toBe("project"); + expect(out.cwd).toBe("/Users/alice/project"); + expect(out.observations).toHaveLength(2); + expect(out.observations[0].hookType).toBe("prompt_submit"); + expect(out.observations[0].userPrompt).toBe("Fix the login bug"); + expect(out.observations[1].hookType).toBe("stop"); + expect(out.observations[1].assistantResponse).toBe("Looking into it now."); + }); + + it("parses tool_use + tool_result pairs", () => { + const out = parseJsonlText(fx("tool-use.jsonl")); + expect(out.sessionId).toBe("sess-tool"); + const kinds = out.observations.map((o) => o.hookType); + expect(kinds).toEqual([ + "prompt_submit", + "pre_tool_use", + "post_tool_use", + "stop", + ]); + const toolCall = out.observations[1]; + expect(toolCall.toolName).toBe("Bash"); + expect((toolCall.toolInput as { command: string }).command).toBe("ls"); + const toolResult = out.observations[2]; + expect(toolResult.toolOutput).toBe("README.md\nsrc\n"); + }); + + it("tolerates malformed lines and marks tool errors", () => { + const out = parseJsonlText(fx("errors.jsonl")); + const errObs = out.observations.find((o) => o.hookType === "post_tool_failure"); + expect(errObs).toBeDefined(); + expect(errObs?.toolOutput).toBe("exit 1"); + }); + + it("falls back to generated sessionId when missing", () => { + const text = JSON.stringify({ + type: "user", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }); + const out = parseJsonlText(text); + expect(out.sessionId).toMatch(/^sess_/); + }); + + it("returns empty observations for blank input", () => { + const out = parseJsonlText(""); + expect(out.observations).toHaveLength(0); + }); + + it("prefers the file's sessionId over the fallback", () => { + const text = [ + JSON.stringify({ + type: "user", + sessionId: "real-session-from-file", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }), + ].join("\n"); + const out = parseJsonlText(text, "fallback-should-be-ignored"); + expect(out.sessionId).toBe("real-session-from-file"); + for (const obs of out.observations) { + expect(obs.sessionId).toBe("real-session-from-file"); + } + }); + + it("returns the same sessionId across repeated parses of one file", () => { + const text = JSON.stringify({ + type: "user", + sessionId: "stable-id", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }); + const a = parseJsonlText(text, "fb-1"); + const b = parseJsonlText(text, "fb-2"); + expect(a.sessionId).toBe("stable-id"); + expect(b.sessionId).toBe("stable-id"); + }); + + it("uses the fallback only when the file has no sessionId", () => { + const text = JSON.stringify({ + type: "user", + timestamp: "2026-01-01T00:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }); + const out = parseJsonlText(text, "fb-used"); + expect(out.sessionId).toBe("fb-used"); + }); +}); + +describe("projectTimeline", () => { + it("preserves ordering and computes offsets from real timestamps", () => { + const parsed = parseJsonlText(fx("tool-use.jsonl")); + const tl = projectTimeline(parsed.observations); + expect(tl.eventCount).toBe(4); + expect(tl.events[0].kind).toBe("prompt"); + expect(tl.events[1].kind).toBe("tool_call"); + expect(tl.events[2].kind).toBe("tool_result"); + expect(tl.events[3].kind).toBe("response"); + expect(tl.events[0].offsetMs).toBe(0); + expect(tl.events[3].offsetMs).toBeGreaterThan(0); + }); + + it("synthesizes pacing when all timestamps identical", () => { + const parsed = parseJsonlText(fx("basic.jsonl")); + for (const obs of parsed.observations) obs.timestamp = "2026-04-17T10:00:00.000Z"; + const tl = projectTimeline(parsed.observations); + expect(tl.events[0].offsetMs).toBe(0); + expect(tl.events[1].offsetMs).toBeGreaterThanOrEqual(300); + }); + + it("returns empty timeline for no observations", () => { + const tl = projectTimeline([]); + expect(tl.eventCount).toBe(0); + expect(tl.totalDurationMs).toBe(0); + expect(tl.events).toHaveLength(0); + }); + + it("marks errored tool results as tool_error kind", () => { + const parsed = parseJsonlText(fx("errors.jsonl")); + const tl = projectTimeline(parsed.observations); + expect(tl.events.some((e) => e.kind === "tool_error")).toBe(true); + }); + + it("uses one shared fallback timestamp when metadata missing", () => { + const text = JSON.stringify({ + type: "user", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }); + const out = parseJsonlText(text); + expect(out.startedAt).toBe(out.endedAt); + }); +}); + diff --git a/test/reranker.test.ts b/test/reranker.test.ts new file mode 100644 index 0000000..0694e6d --- /dev/null +++ b/test/reranker.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@xenova/transformers", () => { + throw new Error("not installed"); +}); + +import { rerank, isRerankerAvailable } from "../src/state/reranker.js"; + +describe("reranker", () => { + it("returns results unchanged when @xenova/transformers is unavailable", async () => { + const results = [ + { + observation: { + id: "o1", + title: "First", + narrative: "First result", + }, + bm25Score: 0.5, + vectorScore: 0.6, + graphScore: 0, + combinedScore: 0.8, + sessionId: "s1", + }, + { + observation: { + id: "o2", + title: "Second", + narrative: "Second result", + }, + bm25Score: 0.3, + vectorScore: 0.4, + graphScore: 0, + combinedScore: 0.5, + sessionId: "s1", + }, + ] as any; + + const reranked = await rerank("test query", results); + expect(reranked).toEqual(results); + }); + + it("isRerankerAvailable returns false when not loaded", () => { + expect(isRerankerAvailable()).toBe(false); + }); + + it("handles single result gracefully", async () => { + const results = [ + { + observation: { id: "o1", title: "Only" }, + combinedScore: 1.0, + }, + ] as any; + + const reranked = await rerank("query", results); + expect(reranked).toHaveLength(1); + }); + + it("handles empty results", async () => { + const reranked = await rerank("query", []); + expect(reranked).toHaveLength(0); + }); +}); diff --git a/test/retention-access.test.ts b/test/retention-access.test.ts new file mode 100644 index 0000000..f6e28f7 --- /dev/null +++ b/test/retention-access.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Memory, SemanticMemory } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV( + memories: Memory[] = [], + semanticMems: SemanticMemory[] = [], +) { + const store = new Map>(); + const memMap = new Map(); + for (const m of memories) memMap.set(m.id, m); + store.set("mem:memories", memMap); + const semMap = new Map(); + for (const s of semanticMems) semMap.set(s.id, s); + store.set("mem:semantic", semMap); + store.set("mem:retention", new Map()); + store.set("mem:access", new Map()); + + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string) => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const m = store.get(scope); + return m ? (Array.from(m.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const fns = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + fns.set(id, fn); + }, + trigger: async ( + input: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const functionId = + typeof input === "string" ? input : input.function_id; + const payload = typeof input === "string" ? data : input.payload; + const fn = fns.get(functionId); + if (fn) return fn(payload); + return null; + }, + }; +} + +function makeMemory(id: string, daysOld = 30): Memory { + const created = new Date( + Date.now() - daysOld * 86_400_000, + ).toISOString(); + return { + id, + createdAt: created, + updatedAt: created, + type: "fact", + title: `Memory ${id}`, + content: `Content ${id}`, + concepts: [], + files: [], + sessionIds: ["ses_1"], + strength: 1, + version: 1, + isLatest: true, + }; +} + +function makeSemantic( + id: string, + daysOld: number, + accessCount = 0, +): SemanticMemory { + const created = new Date( + Date.now() - daysOld * 86_400_000, + ).toISOString(); + return { + id, + fact: `Fact ${id}`, + confidence: 0.8, + sourceSessionIds: ["ses_1"], + sourceMemoryIds: [], + accessCount, + lastAccessedAt: created, + strength: 0.8, + createdAt: created, + updatedAt: created, + }; +} + +describe("RetentionScoring with access log (issue #119)", () => { + it("episodic memories with recorded reads get higher reinforcementBoost than untouched ones", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + const { recordAccess } = await import( + "../src/functions/access-tracker.js" + ); + + const memories = [ + makeMemory("mem_hot", 30), + makeMemory("mem_cold", 30), + ]; + const sdk = mockSdk(); + const kv = mockKV(memories); + registerRetentionFunctions(sdk as never, kv as never); + + // Simulate 5 agent reads of mem_hot in the past 24h + const now = Date.now(); + for (let i = 0; i < 5; i++) { + await recordAccess(kv as never, "mem_hot", now - i * 60_000); + } + + const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any; + + const hot = result.scores.find((s: any) => s.memoryId === "mem_hot"); + const cold = result.scores.find((s: any) => s.memoryId === "mem_cold"); + + expect(hot.accessCount).toBe(5); + expect(cold.accessCount).toBe(0); + expect(hot.reinforcementBoost).toBeGreaterThan(0); + expect(cold.reinforcementBoost).toBe(0); + expect(hot.score).toBeGreaterThan(cold.score); + }); + + it("recent reads contribute more to reinforcement than ancient reads", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + const { recordAccess } = await import( + "../src/functions/access-tracker.js" + ); + + const memories = [ + makeMemory("mem_recent_read", 60), + makeMemory("mem_old_read", 60), + ]; + const sdk = mockSdk(); + const kv = mockKV(memories); + registerRetentionFunctions(sdk as never, kv as never); + + const now = Date.now(); + // mem_recent_read: 1 access yesterday + await recordAccess(kv as never, "mem_recent_read", now - 86_400_000); + // mem_old_read: 1 access 60 days ago + await recordAccess(kv as never, "mem_old_read", now - 60 * 86_400_000); + + const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any; + const recent = result.scores.find( + (s: any) => s.memoryId === "mem_recent_read", + ); + const old = result.scores.find( + (s: any) => s.memoryId === "mem_old_read", + ); + + expect(recent.reinforcementBoost).toBeGreaterThan(old.reinforcementBoost); + }); + + it("backwards-compat: semantic memories with only legacy lastAccessedAt still score", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + // Pre-0.8.3 data: semantic memory has lastAccessedAt set by the + // consolidation pipeline, but no entry in mem:access. The merge in + // retention.ts must inject lastAccessedAt into accessTimestamps so + // the boost is non-zero. Compare against an identical sem with NO + // lastAccessedAt to prove the merge actually contributes. + const semWith = makeSemantic("sem_with_legacy", 30, 3); + semWith.lastAccessedAt = new Date(Date.now() - 86_400_000).toISOString(); + const semWithout = makeSemantic("sem_without_legacy", 30, 3); + semWithout.lastAccessedAt = ""; + + const sdk = mockSdk(); + const kv = mockKV([], [semWith, semWithout]); + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any; + const withEntry = result.scores.find( + (s: any) => s.memoryId === "sem_with_legacy", + ); + const withoutEntry = result.scores.find( + (s: any) => s.memoryId === "sem_without_legacy", + ); + + expect(withEntry.accessCount).toBe(3); + expect(withEntry.reinforcementBoost).toBeGreaterThan(0); + expect(withoutEntry.reinforcementBoost).toBe(0); + // The merged legacy timestamp must produce a meaningful delta. + expect(withEntry.reinforcementBoost).toBeGreaterThan( + withoutEntry.reinforcementBoost + 0.1, + ); + }); + + it("corrupted lastAccessedAt does not propagate NaN into the score", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + const sem = makeSemantic("sem_corrupt", 30, 1); + sem.lastAccessedAt = ""; + + const sdk = mockSdk(); + const kv = mockKV([], [sem]); + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any; + const entry = result.scores.find((s: any) => s.memoryId === "sem_corrupt"); + + expect(Number.isFinite(entry.score)).toBe(true); + expect(Number.isFinite(entry.reinforcementBoost)).toBe(true); + }); + + it("retention scoring normalizes malformed mem:access rows", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + const memories = [ + makeMemory("mem_corrupt", 10), + makeMemory("mem_clean", 10), + ]; + const sdk = mockSdk(); + const kv = mockKV(memories); + + // Seed the access namespace directly with garbage rows. + await kv.set("mem:access", "mem_corrupt", { + memoryId: "mem_corrupt", + count: "not-a-number" as unknown as number, + lastAt: 42 as unknown as string, + recent: [NaN, "bad" as unknown as number, 5_000, Infinity, -1_000], + }); + await kv.set("mem:access", "mem_clean", { + memoryId: "mem_clean", + count: -7, + lastAt: "", + recent: Array.from({ length: 50 }, (_, i) => i * 1000), + }); + + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any; + const corrupt = result.scores.find( + (s: any) => s.memoryId === "mem_corrupt", + ); + const clean = result.scores.find((s: any) => s.memoryId === "mem_clean"); + + expect(Number.isFinite(corrupt.score)).toBe(true); + expect(Number.isFinite(corrupt.reinforcementBoost)).toBe(true); + expect(corrupt.accessCount).toBeGreaterThanOrEqual(0); + expect(Number.isFinite(clean.score)).toBe(true); + // recent[] was 50 entries; normalization should have capped at 20. + expect(clean.accessCount).toBeGreaterThanOrEqual(20); + }); + + it("retention scoring survives kv.list(mem:access) failures", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + const memories = [makeMemory("mem_resilient", 10)]; + const sdk = mockSdk(); + const kv = mockKV(memories); + const realList = kv.list.bind(kv); + kv.list = (async (scope: string) => { + if (scope === "mem:access") throw new Error("namespace missing"); + return realList(scope); + }) as never; + + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any; + expect(result.success).toBe(true); + const entry = result.scores.find( + (s: any) => s.memoryId === "mem_resilient", + ); + expect(entry.accessCount).toBe(0); + }); + + it("fresh access log overrides legacy single-sample for semantic memories", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + const { recordAccess } = await import( + "../src/functions/access-tracker.js" + ); + + const sem = makeSemantic("sem_active", 30, 1); + const sdk = mockSdk(); + const kv = mockKV([], [sem]); + registerRetentionFunctions(sdk as never, kv as never); + + const now = Date.now(); + for (let i = 0; i < 10; i++) { + await recordAccess(kv as never, "sem_active", now - i * 30_000); + } + + const result = (await sdk.trigger({ function_id: "mem::retention-score", payload: {} })) as any; + const entry = result.scores.find( + (s: any) => s.memoryId === "sem_active", + ); + + // effectiveCount = max(log=10, sem.accessCount=1) = 10 + expect(entry.accessCount).toBe(10); + }); +}); diff --git a/test/retention.test.ts b/test/retention.test.ts new file mode 100644 index 0000000..cf8cc68 --- /dev/null +++ b/test/retention.test.ts @@ -0,0 +1,566 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + getSearchIndex, + setIndexPersistence, +} from "../src/functions/search.js"; +import { memoryToObservation } from "../src/state/memory-utils.js"; +import type { Memory, SemanticMemory } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV( + memories: Memory[] = [], + semanticMems: SemanticMemory[] = [], +) { + const store = new Map>(); + + const memMap = new Map(); + for (const m of memories) memMap.set(m.id, m); + store.set("mem:memories", memMap); + + const semMap = new Map(); + for (const s of semanticMems) semMap.set(s.id, s); + store.set("mem:semantic", semMap); + + store.set("mem:retention", new Map()); + + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string, fn: Function) => { + if (typeof idOrOpts !== "string") { + throw new Error("registerFunction expects string function id"); + } + functions.set(idOrOpts, fn); + }, + trigger: async (input: { function_id: string; payload: unknown }) => { + if (typeof input === "string") { + throw new Error("legacy trigger signature is not supported in tests"); + } + const fn = functions.get(input.function_id); + if (fn) return fn(input.payload); + return null; + }, + }; +} + +function makeMemory( + id: string, + type: Memory["type"], + daysOld: number, +): Memory { + const created = new Date( + Date.now() - daysOld * 24 * 60 * 60 * 1000, + ).toISOString(); + return { + id, + createdAt: created, + updatedAt: created, + type, + title: `Memory ${id}`, + content: `Content of memory ${id}`, + concepts: [], + files: [], + sessionIds: ["ses_1"], + strength: 1, + version: 1, + isLatest: true, + }; +} + +function makeSemanticMemory( + id: string, + daysOld: number, + accessCount = 0, +): SemanticMemory { + const created = new Date( + Date.now() - daysOld * 24 * 60 * 60 * 1000, + ).toISOString(); + return { + id, + fact: `Fact ${id}`, + confidence: 0.8, + sourceSessionIds: ["ses_1"], + sourceMemoryIds: [], + accessCount, + lastAccessedAt: created, + strength: 0.8, + createdAt: created, + updatedAt: created, + }; +} + +describe("RetentionScoring", () => { + it("imports without errors", async () => { + const mod = await import("../src/functions/retention.js"); + expect(mod.registerRetentionFunctions).toBeDefined(); + }); + + it("computes retention scores for all memories", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const memories = [ + makeMemory("mem_recent", "architecture", 1), + makeMemory("mem_old", "fact", 365), + ]; + + const sdk = mockSdk(); + const kv = mockKV(memories); + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ + function_id: "mem::retention-score", + payload: {}, + })) as { + success: boolean; + total: number; + tiers: any; + scores: any[]; + }; + + expect(result.success).toBe(true); + expect(result.total).toBe(2); + expect(result.scores.length).toBe(2); + + const recentScore = result.scores.find( + (s: any) => s.memoryId === "mem_recent", + ); + const oldScore = result.scores.find( + (s: any) => s.memoryId === "mem_old", + ); + + expect(recentScore!.score).toBeGreaterThan(oldScore!.score); + }); + + it("higher-type memories get higher salience", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const memories = [ + makeMemory("mem_arch", "architecture", 30), + makeMemory("mem_fact", "fact", 30), + ]; + + const sdk = mockSdk(); + const kv = mockKV(memories); + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ + function_id: "mem::retention-score", + payload: {}, + })) as any; + + const archScore = result.scores.find( + (s: any) => s.memoryId === "mem_arch", + ); + const factScore = result.scores.find( + (s: any) => s.memoryId === "mem_fact", + ); + + expect(archScore.salience).toBeGreaterThan(factScore.salience); + }); + + it("classifies memories into tiers", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const memories = [ + makeMemory("hot1", "architecture", 1), + makeMemory("hot2", "preference", 3), + makeMemory("warm1", "pattern", 60), + makeMemory("cold1", "fact", 300), + ]; + + const sdk = mockSdk(); + const kv = mockKV(memories); + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ + function_id: "mem::retention-score", + payload: {}, + })) as any; + expect(result.tiers.hot + result.tiers.warm + result.tiers.cold + result.tiers.evictable).toBe(4); + }); + + it("dry-run eviction shows candidates without deleting", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const memories = [ + makeMemory("mem_keep", "architecture", 1), + makeMemory("mem_evict", "fact", 500), + ]; + + const sdk = mockSdk(); + const kv = mockKV(memories); + registerRetentionFunctions(sdk as never, kv as never); + + await sdk.trigger({ function_id: "mem::retention-score", payload: {} }); + + const dryResult = (await sdk.trigger({ + function_id: "mem::retention-evict", + payload: { + threshold: 0.5, + dryRun: true, + }, + })) as any; + + expect(dryResult.dryRun).toBe(true); + expect(dryResult.wouldEvict).toBeGreaterThanOrEqual(0); + + const remaining = await kv.list("mem:memories"); + expect(remaining.length).toBe(2); + }); + + it("includes semantic memories in scoring", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const semanticMems = [ + makeSemanticMemory("sem_1", 10, 5), + makeSemanticMemory("sem_2", 200, 0), + ]; + + const sdk = mockSdk(); + const kv = mockKV([], semanticMems); + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ + function_id: "mem::retention-score", + payload: {}, + })) as any; + + expect(result.total).toBe(2); + const sem1 = result.scores.find((s: any) => s.memoryId === "sem_1"); + const sem2 = result.scores.find((s: any) => s.memoryId === "sem_2"); + expect(sem1.score).toBeGreaterThan(sem2.score); + }); + + it("scores tag rows with their source scope (#124)", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const sdk = mockSdk(); + const kv = mockKV( + [makeMemory("mem_ep", "fact", 10)], + [makeSemanticMemory("sem_sem", 10, 2)], + ); + registerRetentionFunctions(sdk as never, kv as never); + + const result = (await sdk.trigger({ + function_id: "mem::retention-score", + payload: {}, + })) as any; + const ep = result.scores.find((s: any) => s.memoryId === "mem_ep"); + const sem = result.scores.find((s: any) => s.memoryId === "sem_sem"); + expect(ep.source).toBe("episodic"); + expect(sem.source).toBe("semantic"); + + // Also assert the source discriminator is persisted to mem:retention, + // not just present in the transient response payload — the eviction + // loop reads back from stored rows, so a regression in kv.set or + // serialization would still pass the in-memory check above. + const [epStored, semStored] = await Promise.all([ + kv.get("mem:retention", "mem_ep"), + kv.get("mem:retention", "sem_sem"), + ]); + expect(epStored).toMatchObject({ source: "episodic" }); + expect(semStored).toMatchObject({ source: "semantic" }); + }); + + it("mem::retention-evict deletes semantic memories from mem:semantic, not mem:memories (#124)", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + // Both are 500 days old with zero access → both will score below + // the default cold threshold. Before #124 the loop silently called + // kv.delete(mem:memories, ) which was a no-op, leaving + // the semantic row in mem:semantic forever. + const sdk = mockSdk(); + const kv = mockKV( + [makeMemory("mem_evict", "fact", 500)], + [makeSemanticMemory("sem_evict", 500, 0)], + ); + registerRetentionFunctions(sdk as never, kv as never); + + await sdk.trigger({ function_id: "mem::retention-score", payload: {} }); + const result = (await sdk.trigger({ + function_id: "mem::retention-evict", + payload: { threshold: 0.9 }, + })) as any; + + expect(result.evicted).toBe(2); + expect(result.evictedEpisodic).toBe(1); + expect(result.evictedSemantic).toBe(1); + + const remainingEp = await kv.list("mem:memories"); + const remainingSem = await kv.list("mem:semantic"); + expect(remainingEp).toHaveLength(0); + expect(remainingSem).toHaveLength(0); + + // Retention score rows also cleaned up for both. + const remainingScores = await kv.list("mem:retention"); + expect(remainingScores).toHaveLength(0); + }); + + it("mem::retention-evict emits a single batched audit record on success (#124, audit policy)", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const sdk = mockSdk(); + const kv = mockKV( + [makeMemory("mem_a", "fact", 500), makeMemory("mem_b", "fact", 500)], + [makeSemanticMemory("sem_c", 500, 0)], + ); + registerRetentionFunctions(sdk as never, kv as never); + + await sdk.trigger({ function_id: "mem::retention-score", payload: {} }); + await sdk.trigger({ + function_id: "mem::retention-evict", + payload: { threshold: 0.9 }, + }); + + // Retention-score ALSO emits an audit row (one per rescore, also + // required by the repo audit-coverage policy), so filter the audit + // log down to just the retention-evict entry we're asserting on. + const allEntries = await kv.list<{ + operation: string; + functionId: string; + targetIds: string[]; + details: Record; + }>("mem:audit"); + const evictEntries = allEntries.filter( + (e) => e.functionId === "mem::retention-evict", + ); + expect(evictEntries).toHaveLength(1); + const [entry] = evictEntries; + expect(entry.operation).toBe("delete"); + expect([...entry.targetIds].sort()).toEqual(["mem_a", "mem_b", "sem_c"]); + expect(entry.details.evicted).toBe(3); + expect(entry.details.evictedEpisodic).toBe(2); + expect(entry.details.evictedSemantic).toBe(1); + }); + + it("mem::retention-evict skips audit when evicted=0 (no spurious audit rows)", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const sdk = mockSdk(); + // Memory is 1 day old → score will be high → nothing falls below + // the strict 0.99 threshold → evict=0 → no evict audit row. + // Retention-score itself still writes one audit row per sweep, + // which is the expected behavior (zero-eviction != zero-rescore), + // so we filter the audit log down to just the evict entries. + const kv = mockKV([makeMemory("mem_keep", "architecture", 1)]); + registerRetentionFunctions(sdk as never, kv as never); + + await sdk.trigger({ function_id: "mem::retention-score", payload: {} }); + await sdk.trigger({ + function_id: "mem::retention-evict", + payload: { threshold: 0.0001 }, + }); + + const allEntries = await kv.list<{ functionId: string }>("mem:audit"); + const evictEntries = allEntries.filter( + (e) => e.functionId === "mem::retention-evict", + ); + expect(evictEntries).toHaveLength(0); + }); + + it("mem::retention-score emits a batched audit row per rescore (#124, audit policy)", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + const sdk = mockSdk(); + const kv = mockKV( + [makeMemory("mem_a", "fact", 10), makeMemory("mem_b", "fact", 10)], + [makeSemanticMemory("sem_c", 10, 2)], + ); + registerRetentionFunctions(sdk as never, kv as never); + + await sdk.trigger({ function_id: "mem::retention-score", payload: {} }); + + const allEntries = await kv.list<{ + operation: string; + functionId: string; + targetIds: string[]; + details: Record; + }>("mem:audit"); + const scoreEntries = allEntries.filter( + (e) => e.functionId === "mem::retention-score", + ); + expect(scoreEntries).toHaveLength(1); + const [entry] = scoreEntries; + expect(entry.operation).toBe("retention_score"); + // targetIds is intentionally empty — a mature store can have 1000+ + // memory ids per rescore and flooding the audit log would be worse + // than recording just the summary counts. + expect(entry.targetIds).toEqual([]); + expect(entry.details.total).toBe(3); + expect(entry.details.episodic).toBe(2); + expect(entry.details.semantic).toBe(1); + }); + + it("mem::retention-evict probes namespaces for legacy semantic rows (backwards-compat, #124)", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + // The actual nasty case from CodeRabbit's review: a pre-0.8.10 + // store that had a semantic memory scored by the old code path. + // The retention row has NO source field and the memory lives in + // mem:semantic. If the eviction path blindly defaults missing + // source to episodic, it no-ops the delete and strands the + // semantic row forever — which is the exact bug #124 is about. + const sdk = mockSdk(); + const kv = mockKV([], [makeSemanticMemory("sem_legacy", 500, 0)]); + registerRetentionFunctions(sdk as never, kv as never); + + await kv.set("mem:retention", "sem_legacy", { + memoryId: "sem_legacy", + // No `source` field — simulates a row written by 0.8.9 or earlier. + score: 0.01, + salience: 0, + temporalDecay: 0, + reinforcementBoost: 0, + lastAccessed: new Date().toISOString(), + accessCount: 0, + }); + + const result = (await sdk.trigger({ + function_id: "mem::retention-evict", + payload: { threshold: 0.5 }, + })) as any; + expect(result.evicted).toBe(1); + expect(result.evictedSemantic).toBe(1); + expect(result.evictedEpisodic).toBe(0); + + // Most important assertion: the semantic row is GONE from + // mem:semantic. Before the probe fix, this assertion failed + // because the delete targeted mem:memories. + const remainingSem = await kv.list("mem:semantic"); + expect(remainingSem).toHaveLength(0); + }); + + it("mem::retention-evict routes pre-0.8.10 episodic rows with missing source to mem:memories (#124)", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + + // Simulate a store that was scored on 0.8.9 or earlier: retention + // rows exist but they have no `source` field. The new eviction + // loop must still route those to mem:memories so users don't get + // stuck with un-evictable episodic rows after upgrading. + const sdk = mockSdk(); + const kv = mockKV([makeMemory("mem_old", "fact", 500)]); + registerRetentionFunctions(sdk as never, kv as never); + + // Directly plant a legacy-shape retention score (no `source` key). + await kv.set("mem:retention", "mem_old", { + memoryId: "mem_old", + score: 0.01, + salience: 0, + temporalDecay: 0, + reinforcementBoost: 0, + lastAccessed: new Date().toISOString(), + accessCount: 0, + }); + + const result = (await sdk.trigger({ + function_id: "mem::retention-evict", + payload: { threshold: 0.5 }, + })) as any; + expect(result.evicted).toBe(1); + expect(result.evictedEpisodic).toBe(1); + expect(result.evictedSemantic).toBe(0); + const remaining = await kv.list("mem:memories"); + expect(remaining).toHaveLength(0); + }); + + describe("search-index cleanup on eviction", () => { + beforeEach(() => { + getSearchIndex().clear(); + setIndexPersistence(null); + }); + + afterEach(() => { + setIndexPersistence(null); + }); + + it("removes evicted memories from the BM25 index and flushes persistence", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) }; + setIndexPersistence(persistence); + + const evictee = makeMemory("mem_evict", "fact", 500); + const sdk = mockSdk(); + const kv = mockKV([evictee]); + registerRetentionFunctions(sdk as never, kv as never); + getSearchIndex().add(memoryToObservation(evictee)); + expect(getSearchIndex().has("mem_evict")).toBe(true); + + await sdk.trigger({ function_id: "mem::retention-score", payload: {} }); + await sdk.trigger({ + function_id: "mem::retention-evict", + payload: { threshold: 0.9 }, + }); + + expect(getSearchIndex().has("mem_evict")).toBe(false); + expect(persistence.save).toHaveBeenCalled(); + }); + + it("does not flush persistence when nothing is evicted", async () => { + const { registerRetentionFunctions } = await import( + "../src/functions/retention.js" + ); + const persistence = { scheduleSave: vi.fn(), save: vi.fn(async () => {}) }; + setIndexPersistence(persistence); + + const sdk = mockSdk(); + const kv = mockKV([makeMemory("mem_fresh", "architecture", 1)]); + registerRetentionFunctions(sdk as never, kv as never); + + await sdk.trigger({ function_id: "mem::retention-score", payload: {} }); + await sdk.trigger({ + function_id: "mem::retention-evict", + payload: { threshold: 0.01 }, + }); + + expect(persistence.save).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/routines.test.ts b/test/routines.test.ts new file mode 100644 index 0000000..f5a66a7 --- /dev/null +++ b/test/routines.test.ts @@ -0,0 +1,498 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerRoutinesFunction } from "../src/functions/routines.js"; +import type { Action, Routine, RoutineRun } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Routines Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerRoutinesFunction(sdk as never, kv as never); + }); + + describe("mem::routine-create", () => { + it("creates a routine with valid data", async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "Deploy Pipeline", + description: "Standard deploy steps", + steps: [ + { title: "Build", description: "Run build", actionTemplate: {}, dependsOn: [] }, + { title: "Test", description: "Run tests", actionTemplate: {}, dependsOn: [0] }, + ], + tags: ["deploy", "ci"], + })) as { success: boolean; routine: Routine }; + + expect(result.success).toBe(true); + expect(result.routine.id).toMatch(/^rtn_/); + expect(result.routine.name).toBe("Deploy Pipeline"); + expect(result.routine.description).toBe("Standard deploy steps"); + expect(result.routine.steps.length).toBe(2); + expect(result.routine.tags).toEqual(["deploy", "ci"]); + expect(result.routine.createdAt).toBeDefined(); + expect(result.routine.updatedAt).toBeDefined(); + expect(result.routine.frozen).toBe(true); + }); + + it("returns error when name is missing", async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "", + steps: [{ title: "Step 1", description: "", actionTemplate: {}, dependsOn: [] }], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("name and steps are required"); + }); + + it("returns error when steps array is empty", async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "Empty Routine", + steps: [], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("name and steps are required"); + }); + + it("returns error when a step has no title", async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "Bad Steps", + steps: [ + { title: "Good Step", description: "ok", actionTemplate: {}, dependsOn: [] }, + { title: " ", description: "no title", actionTemplate: {}, dependsOn: [] }, + ], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("step 1 must have a title"); + }); + + it("assigns correct order to steps", async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "Ordered Routine", + steps: [ + { title: "First", description: "", actionTemplate: {}, dependsOn: [] }, + { title: "Second", description: "", actionTemplate: {}, dependsOn: [] }, + { title: "Third", description: "", actionTemplate: {}, dependsOn: [] }, + ], + })) as { success: boolean; routine: Routine }; + + expect(result.success).toBe(true); + expect(result.routine.steps[0].order).toBe(0); + expect(result.routine.steps[1].order).toBe(1); + expect(result.routine.steps[2].order).toBe(2); + }); + + it("preserves explicit order values", async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "Custom Order", + steps: [ + { order: 10, title: "First", description: "", actionTemplate: {}, dependsOn: [] }, + { order: 20, title: "Second", description: "", actionTemplate: {}, dependsOn: [] }, + ], + })) as { success: boolean; routine: Routine }; + + expect(result.success).toBe(true); + expect(result.routine.steps[0].order).toBe(10); + expect(result.routine.steps[1].order).toBe(20); + }); + + it("defaults frozen to true when not specified", async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "Default Frozen", + steps: [{ title: "Step", description: "", actionTemplate: {}, dependsOn: [] }], + })) as { success: boolean; routine: Routine }; + + expect(result.success).toBe(true); + expect(result.routine.frozen).toBe(true); + }); + + it("respects frozen=false when explicitly set", async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "Unfrozen", + steps: [{ title: "Step", description: "", actionTemplate: {}, dependsOn: [] }], + frozen: false, + })) as { success: boolean; routine: Routine }; + + expect(result.success).toBe(true); + expect(result.routine.frozen).toBe(false); + }); + }); + + describe("mem::routine-list", () => { + beforeEach(async () => { + await sdk.trigger("mem::routine-create", { + name: "Routine A", + steps: [{ title: "S1", description: "", actionTemplate: {}, dependsOn: [] }], + tags: ["deploy"], + frozen: true, + }); + await sdk.trigger("mem::routine-create", { + name: "Routine B", + steps: [{ title: "S1", description: "", actionTemplate: {}, dependsOn: [] }], + tags: ["test", "ci"], + frozen: false, + }); + await sdk.trigger("mem::routine-create", { + name: "Routine C", + steps: [{ title: "S1", description: "", actionTemplate: {}, dependsOn: [] }], + tags: ["deploy", "ci"], + frozen: true, + }); + }); + + it("lists all routines", async () => { + const result = (await sdk.trigger("mem::routine-list", {})) as { + success: boolean; + routines: Routine[]; + }; + + expect(result.success).toBe(true); + expect(result.routines.length).toBe(3); + }); + + it("filters by frozen=true", async () => { + const result = (await sdk.trigger("mem::routine-list", { + frozen: true, + })) as { success: boolean; routines: Routine[] }; + + expect(result.success).toBe(true); + expect(result.routines.length).toBe(2); + expect(result.routines.every((r) => r.frozen === true)).toBe(true); + }); + + it("filters by frozen=false", async () => { + const result = (await sdk.trigger("mem::routine-list", { + frozen: false, + })) as { success: boolean; routines: Routine[] }; + + expect(result.success).toBe(true); + expect(result.routines.length).toBe(1); + expect(result.routines[0].name).toBe("Routine B"); + }); + + it("filters by tags", async () => { + const result = (await sdk.trigger("mem::routine-list", { + tags: ["deploy"], + })) as { success: boolean; routines: Routine[] }; + + expect(result.success).toBe(true); + expect(result.routines.length).toBe(2); + const names = result.routines.map((r) => r.name); + expect(names).toContain("Routine A"); + expect(names).toContain("Routine C"); + }); + + it("filters by tags with multiple matches", async () => { + const result = (await sdk.trigger("mem::routine-list", { + tags: ["ci"], + })) as { success: boolean; routines: Routine[] }; + + expect(result.success).toBe(true); + expect(result.routines.length).toBe(2); + const names = result.routines.map((r) => r.name); + expect(names).toContain("Routine B"); + expect(names).toContain("Routine C"); + }); + }); + + describe("mem::routine-run", () => { + let routineId: string; + + beforeEach(async () => { + const result = (await sdk.trigger("mem::routine-create", { + name: "Test Pipeline", + steps: [ + { order: 0, title: "Build", description: "Build step", actionTemplate: { priority: 3 }, dependsOn: [] }, + { order: 1, title: "Test", description: "Test step", actionTemplate: { priority: 5 }, dependsOn: [0] }, + { order: 2, title: "Deploy", description: "Deploy step", actionTemplate: {}, dependsOn: [0, 1] }, + ], + })) as { success: boolean; routine: Routine }; + routineId = result.routine.id; + }); + + it("creates actions for each step", async () => { + const result = (await sdk.trigger("mem::routine-run", { + routineId, + initiatedBy: "user-1", + })) as { success: boolean; run: RoutineRun; actionsCreated: number }; + + expect(result.success).toBe(true); + expect(result.actionsCreated).toBe(3); + expect(result.run.actionIds.length).toBe(3); + expect(result.run.status).toBe("running"); + expect(result.run.initiatedBy).toBe("user-1"); + + const actions = await kv.list("mem:actions"); + expect(actions.length).toBe(3); + const titles = actions.map((a) => a.title); + expect(titles).toContain("Build"); + expect(titles).toContain("Test"); + expect(titles).toContain("Deploy"); + }); + + it("creates dependency edges between steps", async () => { + const result = (await sdk.trigger("mem::routine-run", { + routineId, + })) as { success: boolean; run: RoutineRun; actionsCreated: number }; + + expect(result.success).toBe(true); + + const edges = await kv.list<{ + id: string; + type: string; + sourceActionId: string; + targetActionId: string; + }>("mem:action-edges"); + + expect(edges.length).toBe(3); + expect(edges.every((e) => e.type === "requires")).toBe(true); + }); + + it("creates routine run tracking object", async () => { + const result = (await sdk.trigger("mem::routine-run", { + routineId, + initiatedBy: "agent-x", + })) as { success: boolean; run: RoutineRun }; + + expect(result.run.id).toMatch(/^run_/); + expect(result.run.routineId).toBe(routineId); + expect(result.run.status).toBe("running"); + expect(result.run.startedAt).toBeDefined(); + expect(result.run.actionIds.length).toBe(3); + expect(result.run.initiatedBy).toBe("agent-x"); + + const stored = await kv.get("mem:routine-runs", result.run.id); + expect(stored).not.toBeNull(); + expect(stored!.routineId).toBe(routineId); + }); + + it("preserves priority 0 via nullish coalescing", async () => { + const createResult = (await sdk.trigger("mem::routine-create", { + name: "Zero Priority", + steps: [ + { order: 0, title: "Step Zero", description: "", actionTemplate: { priority: 0 }, dependsOn: [] }, + ], + })) as { success: boolean; routine: Routine }; + + const runResult = (await sdk.trigger("mem::routine-run", { + routineId: createResult.routine.id, + })) as { success: boolean; run: RoutineRun }; + + const actionId = runResult.run.actionIds[0]; + const action = await kv.get("mem:actions", actionId); + expect(action!.priority).toBe(0); + }); + + it("tags actions with routine id", async () => { + const result = (await sdk.trigger("mem::routine-run", { + routineId, + })) as { success: boolean; run: RoutineRun }; + + for (const actionId of result.run.actionIds) { + const action = await kv.get("mem:actions", actionId); + expect(action!.tags).toContain(`routine:${routineId}`); + } + }); + + it("returns error when routineId is missing", async () => { + const result = (await sdk.trigger("mem::routine-run", { + routineId: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("routineId is required"); + }); + + it("returns error when routine is not found", async () => { + const result = (await sdk.trigger("mem::routine-run", { + routineId: "rtn_nonexistent", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("routine not found"); + }); + }); + + describe("mem::routine-status", () => { + let runId: string; + let actionIds: string[]; + + beforeEach(async () => { + const createResult = (await sdk.trigger("mem::routine-create", { + name: "Status Test", + steps: [ + { order: 0, title: "Step A", description: "", actionTemplate: {}, dependsOn: [] }, + { order: 1, title: "Step B", description: "", actionTemplate: {}, dependsOn: [] }, + { order: 2, title: "Step C", description: "", actionTemplate: {}, dependsOn: [] }, + ], + })) as { success: boolean; routine: Routine }; + + const runResult = (await sdk.trigger("mem::routine-run", { + routineId: createResult.routine.id, + })) as { success: boolean; run: RoutineRun }; + + runId = runResult.run.id; + actionIds = runResult.run.actionIds; + }); + + it("reports running status when actions are in progress", async () => { + const result = (await sdk.trigger("mem::routine-status", { + runId, + })) as { success: boolean; run: RoutineRun; progress: { total: number; pending: number } }; + + expect(result.success).toBe(true); + expect(result.run.status).toBe("running"); + expect(result.progress.total).toBe(3); + expect(result.progress.pending).toBe(3); + }); + + it("marks run completed when all actions are done", async () => { + for (const actionId of actionIds) { + const action = await kv.get("mem:actions", actionId); + action!.status = "done"; + await kv.set("mem:actions", actionId, action); + } + + const result = (await sdk.trigger("mem::routine-status", { + runId, + })) as { success: boolean; run: RoutineRun; progress: { done: number; total: number } }; + + expect(result.success).toBe(true); + expect(result.run.status).toBe("completed"); + expect(result.run.completedAt).toBeDefined(); + expect(result.progress.done).toBe(3); + }); + + it("marks run failed when any action is cancelled", async () => { + const action = await kv.get("mem:actions", actionIds[0]); + action!.status = "cancelled"; + await kv.set("mem:actions", actionIds[0], action); + + const result = (await sdk.trigger("mem::routine-status", { + runId, + })) as { success: boolean; run: RoutineRun }; + + expect(result.success).toBe(true); + expect(result.run.status).toBe("failed"); + }); + + it("marks run failed when any action is cancelled (mixed statuses)", async () => { + const action = await kv.get("mem:actions", actionIds[1]); + (action as Action).status = "done"; + await kv.set("mem:actions", actionIds[1], action); + + const action2 = await kv.get("mem:actions", actionIds[2]); + (action2 as Action).status = "cancelled"; + await kv.set("mem:actions", actionIds[2], action2); + + const result = (await sdk.trigger("mem::routine-status", { + runId, + })) as { success: boolean; run: RoutineRun }; + + expect(result.success).toBe(true); + expect(result.run.status).toBe("failed"); + }); + + it("returns error when runId is missing", async () => { + const result = (await sdk.trigger("mem::routine-status", { + runId: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("runId is required"); + }); + + it("returns error when run is not found", async () => { + const result = (await sdk.trigger("mem::routine-status", { + runId: "run_nonexistent", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("run not found"); + }); + }); + + describe("mem::routine-freeze", () => { + it("freezes a routine", async () => { + const createResult = (await sdk.trigger("mem::routine-create", { + name: "Unfreeze Me", + steps: [{ title: "Step", description: "", actionTemplate: {}, dependsOn: [] }], + frozen: false, + })) as { success: boolean; routine: Routine }; + + expect(createResult.routine.frozen).toBe(false); + + const result = (await sdk.trigger("mem::routine-freeze", { + routineId: createResult.routine.id, + })) as { success: boolean; routine: Routine }; + + expect(result.success).toBe(true); + expect(result.routine.frozen).toBe(true); + expect(result.routine.updatedAt).toBeDefined(); + }); + + it("returns error when routineId is missing", async () => { + const result = (await sdk.trigger("mem::routine-freeze", { + routineId: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("routineId is required"); + }); + + it("returns error when routine is not found", async () => { + const result = (await sdk.trigger("mem::routine-freeze", { + routineId: "rtn_nonexistent", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("routine not found"); + }); + }); +}); diff --git a/test/schema-fingerprint.test.ts b/test/schema-fingerprint.test.ts new file mode 100644 index 0000000..f56af2c --- /dev/null +++ b/test/schema-fingerprint.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { fingerprintId, KV } from "../src/state/schema.js"; + +describe("fingerprintId", () => { + it("returns string with correct prefix", () => { + const id = fingerprintId("mem", "some content"); + expect(id).toMatch(/^mem_/); + }); + + it("same content produces same ID (deterministic)", () => { + const id1 = fingerprintId("obs", "identical content here"); + const id2 = fingerprintId("obs", "identical content here"); + expect(id1).toBe(id2); + }); + + it("different content produces different IDs", () => { + const id1 = fingerprintId("obs", "content alpha"); + const id2 = fingerprintId("obs", "content beta"); + expect(id1).not.toBe(id2); + }); + + it("different prefixes produce different IDs", () => { + const id1 = fingerprintId("mem", "same content"); + const id2 = fingerprintId("obs", "same content"); + expect(id1).not.toBe(id2); + }); + + it("ID has sufficient length (prefix + underscore + 16 hex chars)", () => { + const id = fingerprintId("mem", "test"); + const parts = id.split("_"); + expect(parts.length).toBe(2); + expect(parts[0]).toBe("mem"); + expect(parts[1]).toHaveLength(16); + expect(parts[1]).toMatch(/^[0-9a-f]{16}$/); + }); + + it("handles empty content", () => { + const id = fingerprintId("x", ""); + expect(id).toMatch(/^x_[0-9a-f]{16}$/); + }); + + it("handles long content", () => { + const longContent = "a".repeat(10000); + const id = fingerprintId("long", longContent); + expect(id).toMatch(/^long_[0-9a-f]{16}$/); + }); +}); + +describe("KV scopes", () => { + it("has actions scope", () => { + expect(KV.actions).toBe("mem:actions"); + }); + + it("has actionEdges scope", () => { + expect(KV.actionEdges).toBe("mem:action-edges"); + }); + + it("has leases scope", () => { + expect(KV.leases).toBe("mem:leases"); + }); + + it("has routines scope", () => { + expect(KV.routines).toBe("mem:routines"); + }); + + it("has routineRuns scope", () => { + expect(KV.routineRuns).toBe("mem:routine-runs"); + }); + + it("has signals scope", () => { + expect(KV.signals).toBe("mem:signals"); + }); + + it("has checkpoints scope", () => { + expect(KV.checkpoints).toBe("mem:checkpoints"); + }); + + it("has mesh scope", () => { + expect(KV.mesh).toBe("mem:mesh"); + }); +}); diff --git a/test/schema.test.ts b/test/schema.test.ts new file mode 100644 index 0000000..2d3f65d --- /dev/null +++ b/test/schema.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { KV, STREAM, generateId } from '../src/state/schema.js' + +describe('KV', () => { + it('has correct session scope', () => { + expect(KV.sessions).toBe('mem:sessions') + }) + + it('generates observation scope with session ID', () => { + expect(KV.observations('ses_123')).toBe('mem:obs:ses_123') + }) + + it('has correct summaries scope', () => { + expect(KV.summaries).toBe('mem:summaries') + }) +}) + +describe('STREAM', () => { + it('has correct name', () => { + expect(STREAM.name).toBe('mem-live') + }) + + it('group returns session ID', () => { + expect(STREAM.group('ses_123')).toBe('ses_123') + }) +}) + +describe('generateId', () => { + it('includes prefix', () => { + expect(generateId('obs')).toMatch(/^obs_/) + }) + + it('generates unique IDs', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateId('test'))) + expect(ids.size).toBe(100) + }) + + it('has sufficient length', () => { + const id = generateId('obs') + expect(id.length).toBeGreaterThan(15) + }) +}) diff --git a/test/search-index.test.ts b/test/search-index.test.ts new file mode 100644 index 0000000..805faec --- /dev/null +++ b/test/search-index.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { SearchIndex } from "../src/state/search-index.js"; +import { segmentCjk } from "../src/state/cjk-segmenter.js"; +import type { CompressedObservation } from "../src/types.js"; + +function makeObs( + overrides: Partial = {}, +): CompressedObservation { + return { + id: "obs_1", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + type: "file_edit", + title: "Edit auth middleware", + subtitle: "JWT validation", + facts: ["Added token check"], + narrative: "Modified the auth middleware to validate JWT tokens", + concepts: ["authentication", "jwt"], + files: ["src/middleware/auth.ts"], + importance: 7, + ...overrides, + }; +} + +describe("SearchIndex", () => { + let index: SearchIndex; + + beforeEach(() => { + index = new SearchIndex(); + }); + + it("starts empty", () => { + expect(index.size).toBe(0); + }); + + it("adds and finds observations", () => { + index.add(makeObs()); + expect(index.size).toBe(1); + const results = index.search("auth"); + expect(results.length).toBe(1); + expect(results[0].obsId).toBe("obs_1"); + }); + + it("returns empty for no matches", () => { + index.add(makeObs()); + expect(index.search("database")).toEqual([]); + }); + + it("scores exact matches higher than prefix matches", () => { + index.add( + makeObs({ + id: "obs_exact", + title: "redis cache", + narrative: "Set up redis caching layer", + concepts: ["redis"], + facts: ["Added redis"], + files: ["src/redis.ts"], + }), + ); + index.add( + makeObs({ + id: "obs_prefix", + title: "redistool handler", + narrative: "Set up redistool for ops", + concepts: ["redistool"], + facts: ["Added redistool"], + files: ["src/redistool.ts"], + }), + ); + const results = index.search("redis"); + const exact = results.find((r) => r.obsId === "obs_exact"); + const prefix = results.find((r) => r.obsId === "obs_prefix"); + expect(exact).toBeDefined(); + expect(prefix).toBeDefined(); + expect(exact!.score).toBeGreaterThanOrEqual(prefix!.score); + }); + + it("respects limit", () => { + for (let i = 0; i < 30; i++) { + index.add(makeObs({ id: `obs_${i}`, title: `auth feature ${i}` })); + } + expect(index.search("auth", 5).length).toBe(5); + }); + + it("clears the index", () => { + index.add(makeObs()); + index.clear(); + expect(index.size).toBe(0); + expect(index.search("auth")).toEqual([]); + }); + + // Regression coverage: deleted docs must not keep occupying result + // slots after remove(). Without remove(), a limit-capped search can + // return fewer live results than requested because the slot is held + // by a doc that no longer exists in storage. + describe("remove", () => { + it("drops a removed doc from search results", () => { + index.add(makeObs({ id: "obs_a", title: "jose for JWT" })); + index.add( + makeObs({ id: "obs_b", title: "JWT tokens expire after 30 minutes" }), + ); + expect(index.search("jose jwt", 1)[0].obsId).toBe("obs_a"); + + index.remove("obs_a"); + + // With limit=1, the survivor must now surface instead of getting + // masked by the deleted doc's slot. + const after = index.search("jose jwt", 1); + expect(after).toHaveLength(1); + expect(after[0].obsId).toBe("obs_b"); + expect(index.size).toBe(1); + }); + + it("is a no-op for unknown ids", () => { + index.add(makeObs({ id: "obs_a" })); + expect(() => index.remove("does_not_exist")).not.toThrow(); + expect(index.size).toBe(1); + expect(index.search("auth")).toHaveLength(1); + }); + + it("cleans up empty posting lists and the prefix cache", () => { + // unique_concept_xyz appears only in obs_a. After removing obs_a, + // a prefix search for "unique_" must NOT surface it from the + // sortedTerms cache. + index.add( + makeObs({ + id: "obs_a", + concepts: ["unique_concept_xyz"], + }), + ); + index.add(makeObs({ id: "obs_b", title: "completely different" })); + // Prime the sortedTerms cache. + expect(index.search("unique_")).toHaveLength(1); + + index.remove("obs_a"); + + expect(index.search("unique_concept_xyz")).toEqual([]); + expect(index.search("unique_")).toEqual([]); + }); + + it("keeps scoring consistent after add/remove/add cycles", () => { + // totalDocLength bookkeeping desync would skew BM25 normalization + // (docLen / avgDocLen). Round-trip add → remove → re-add and + // confirm size + retrievability hold. + const obs = makeObs({ id: "obs_a" }); + index.add(obs); + expect(index.size).toBe(1); + index.remove("obs_a"); + expect(index.size).toBe(0); + index.add(obs); + expect(index.size).toBe(1); + expect(index.search("auth")[0].obsId).toBe("obs_a"); + }); + + it("survives serialize/deserialize after removes", () => { + index.add(makeObs({ id: "obs_a", title: "alpha doc" })); + index.add(makeObs({ id: "obs_b", title: "beta doc" })); + index.remove("obs_a"); + + const restored = SearchIndex.deserialize(index.serialize()); + expect(restored.size).toBe(1); + expect(restored.search("alpha")).toEqual([]); + expect(restored.search("beta")).toHaveLength(1); + }); + }); + + it("returns empty for empty query", () => { + index.add(makeObs()); + expect(index.search("")).toEqual([]); + }); + + it("searches across multiple fields", () => { + index.add( + makeObs({ id: "obs_file", title: "something", files: ["auth.ts"] }), + ); + expect(index.search("auth").length).toBe(1); + }); + + it("handles multiple query terms", () => { + index.add( + makeObs({ + id: "obs_both", + title: "redis cache", + narrative: "Set up redis and cache layer", + concepts: ["redis", "cache"], + facts: ["Added caching"], + files: ["src/cache.ts"], + }), + ); + index.add( + makeObs({ + id: "obs_one", + title: "redis only", + narrative: "Set up redis connection", + concepts: ["redis"], + facts: ["Added redis"], + files: ["src/redis.ts"], + }), + ); + const results = index.search("redis cache"); + expect(results[0].obsId).toBe("obs_both"); + expect(results[0].score).toBeGreaterThan(results[1].score); + }); + + it("indexes and finds non-ASCII (Greek) text", () => { + index.add( + makeObs({ + id: "obs_greek", + title: "Προβολή μνήμης", + narrative: "Δοκιμάζουμε αναζήτηση σε ελληνικά", + concepts: ["δοκιμή", "μνήμη"], + }), + ); + const results = index.search("μνήμη"); + expect(results.length).toBe(1); + expect(results[0].obsId).toBe("obs_greek"); + }); + + it("tokenizes mixed ASCII and non-ASCII (Greek) queries", () => { + index.add( + makeObs({ + id: "obs_mixed", + title: "JWT middleware ρύθμιση", + narrative: "Configured JWT with ελληνικά σχόλια", + concepts: ["auth", "jwt", "ρύθμιση"], + }), + ); + const results = index.search("JWT ρύθμιση"); + expect(results.length).toBe(1); + expect(results[0].obsId).toBe("obs_mixed"); + }); + + it("segments Chinese (Han) text into words", () => { + index.add( + makeObs({ + id: "obs_zh", + title: "项目记忆存储", + narrative: "我们正在测试中文分词", + concepts: ["项目", "记忆"], + }), + ); + const results = index.search("项目"); + expect(results.length).toBeGreaterThan(0); + const hit = results.find((r) => r.obsId === "obs_zh"); + expect(hit).toBeDefined(); + expect(hit!.score).toBeGreaterThan(0); + }); + + it("segments Japanese (kana + kanji) text into words", () => { + index.add( + makeObs({ + id: "obs_ja", + title: "プロジェクト記憶", + narrative: "日本語の分かち書きをテストしています", + concepts: ["プロジェクト", "記憶"], + }), + ); + const results = index.search("プロジェクト"); + expect(results.length).toBeGreaterThan(0); + const hit = results.find((r) => r.obsId === "obs_ja"); + expect(hit).toBeDefined(); + expect(hit!.score).toBeGreaterThan(0); + }); + + it("segments Korean (Hangul) syllable blocks into words", () => { + index.add( + makeObs({ + id: "obs_ko", + title: "프로젝트 메모리 저장소", + narrative: "한국어 검색을 테스트합니다", + concepts: ["프로젝트", "메모리"], + }), + ); + const results = index.search("메모리"); + expect(results.length).toBeGreaterThan(0); + const hit = results.find((r) => r.obsId === "obs_ko"); + expect(hit).toBeDefined(); + expect(hit!.score).toBeGreaterThan(0); + }); + + it("preserves source order across mixed CJK and non-CJK runs", () => { + expect(segmentCjk("hello 项目 world")).toEqual(["hello", "项目", "world"]); + expect(segmentCjk("abc 메모리 def 项目 ghi")).toEqual([ + "abc", + "메모리", + "def", + "项目", + "ghi", + ]); + expect(segmentCjk("leading 项目")).toEqual(["leading", "项目"]); + expect(segmentCjk("项目 trailing")).toEqual(["项目", "trailing"]); + }); +}); diff --git a/test/search.test.ts b/test/search.test.ts new file mode 100644 index 0000000..7bc635c --- /dev/null +++ b/test/search.test.ts @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerSearchFunction, getSearchIndex, rebuildIndex, setVectorIndex, setEmbeddingProvider, getVectorIndex } from "../src/functions/search.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import { KV } from "../src/state/schema.js"; +import type { CompressedObservation, Session } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("mem::search", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerSearchFunction(sdk as never, kv as never); + + const session: Session = { + id: "ses_1", + project: "demo", + cwd: "/tmp/demo", + startedAt: "2026-01-01T00:00:00Z", + status: "completed", + observationCount: 2, + }; + await kv.set(KV.sessions, session.id, session); + + const obsA: CompressedObservation = { + id: "obs_a", + sessionId: "ses_1", + timestamp: "2026-01-01T00:00:00Z", + type: "decision", + title: "Auth middleware decision", + subtitle: "JWT strategy", + facts: ["Use rotating refresh tokens"], + narrative: "Implemented auth middleware with JWT refresh rotation.", + concepts: ["auth", "jwt"], + files: ["src/auth.ts"], + importance: 8, + }; + const obsB: CompressedObservation = { + id: "obs_b", + sessionId: "ses_1", + timestamp: "2026-01-02T00:00:00Z", + type: "file_edit", + title: "UI button styling", + facts: ["Updated primary button color"], + narrative: "Adjusted button styles in the settings page.", + concepts: ["ui", "css"], + files: ["src/ui/button.tsx"], + importance: 4, + }; + + await kv.set(KV.observations("ses_1"), obsA.id, obsA); + await kv.set(KV.observations("ses_1"), obsB.id, obsB); + + // Module-level SearchIndex singleton would leak across tests; reset. + getSearchIndex().clear(); + }); + + it("returns full format by default", async () => { + const result = (await sdk.trigger("mem::search", { + query: "auth middleware", + })) as { format: string; results: Array<{ observation: CompressedObservation }> }; + + expect(result.format).toBe("full"); + expect(result.results).toHaveLength(1); + expect(result.results[0]?.observation.id).toBe("obs_a"); + }); + + it("returns compact format when requested", async () => { + const result = (await sdk.trigger("mem::search", { + query: "auth", + format: "compact", + })) as { format: string; results: Array<{ obsId: string; title: string }> }; + + expect(result.format).toBe("compact"); + expect(result.results[0]?.obsId).toBe("obs_a"); + expect(result.results[0]?.title).toBe("Auth middleware decision"); + }); + + it("returns narrative text and respects token budget", async () => { + const result = (await sdk.trigger("mem::search", { + query: "auth ui", + format: "narrative", + token_budget: 20, + })) as { + format: string; + results: Array<{ obsId: string }>; + text: string; + tokens_used: number; + tokens_budget: number; + truncated: boolean; + }; + + expect(result.format).toBe("narrative"); + expect(result.tokens_budget).toBe(20); + expect(result.tokens_used).toBeLessThanOrEqual(20); + expect(typeof result.text).toBe("string"); + expect(result.results.length).toBeLessThanOrEqual(2); + expect(result.truncated).toBe(true); + }); + + it("rejects invalid format values", async () => { + await expect( + sdk.trigger("mem::search", { query: "auth", format: "verbose" }), + ).rejects.toThrow("format must be one of"); + }); + + it("surfaces saved memories from KV.memories (#265)", async () => { + // mem::remember persists to KV.memories under a synthetic sessionId + // ("memory") that has no corresponding KV.observations entry. mem::search + // must fall back to KV.memories or memory_recall returns empty. + await kv.set(KV.memories, "mem_x1", { + id: "mem_x1", + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type: "fact", + title: "Pineapple belongs on pizza", + content: "Pineapple belongs on pizza for testing fallback path.", + concepts: ["pineapple", "pizza"], + files: [], + sessionIds: [], + strength: 7, + version: 1, + isLatest: true, + }); + // Force the rebuild to pick up the new memory (mem::search only + // rebuilds on first call when idx.size === 0). + await rebuildIndex(kv as never); + + const result = (await sdk.trigger("mem::search", { + query: "pineapple pizza", + format: "compact", + })) as { results: Array<{ obsId: string; title: string }> }; + + const hit = result.results.find((r) => r.obsId === "mem_x1"); + expect(hit).toBeDefined(); + expect(hit?.title).toBe("Pineapple belongs on pizza"); + }); + + it("rebuildIndex populates the vector index", async () => { + const mockEmbedder = { + name: "test", + dimensions: 3, + embed: async (_text: string) => new Float32Array([0.1, 0.2, 0.3]), + embedBatch: async (_texts: string[]) => + _texts.map(() => new Float32Array([0.1, 0.2, 0.3])), + }; + setEmbeddingProvider(mockEmbedder); + setVectorIndex(new VectorIndex()); + + await rebuildIndex(kv as never); + + const vi = getVectorIndex(); + expect(vi).not.toBeNull(); + expect(vi!.size).toBeGreaterThan(0); + + // Cleanup + setVectorIndex(null); + setEmbeddingProvider(null); + }); +}); diff --git a/test/sentinels.test.ts b/test/sentinels.test.ts new file mode 100644 index 0000000..39c19a7 --- /dev/null +++ b/test/sentinels.test.ts @@ -0,0 +1,627 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerSentinelsFunction } from "../src/functions/sentinels.js"; +import { registerActionsFunction } from "../src/functions/actions.js"; +import type { Action, ActionEdge, Sentinel } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Sentinels Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerSentinelsFunction(sdk as never, kv as never); + registerActionsFunction(sdk as never, kv as never); + }); + + describe("mem::sentinel-create", () => { + it("creates a webhook sentinel with valid config", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "deploy-hook", + type: "webhook", + config: { path: "/hooks/deploy" }, + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.id).toMatch(/^snl_/); + expect(result.sentinel.name).toBe("deploy-hook"); + expect(result.sentinel.type).toBe("webhook"); + expect(result.sentinel.status).toBe("watching"); + expect(result.sentinel.config).toEqual({ path: "/hooks/deploy" }); + expect(result.sentinel.linkedActionIds).toEqual([]); + expect(result.sentinel.createdAt).toBeDefined(); + }); + + it("creates a timer sentinel with valid config", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "timeout-check", + type: "timer", + config: { durationMs: 5000 }, + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.type).toBe("timer"); + expect(result.sentinel.status).toBe("watching"); + }); + + it("creates a threshold sentinel with valid config", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "high-calls", + type: "threshold", + config: { metric: "api_calls", operator: "gt", value: 100 }, + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.type).toBe("threshold"); + }); + + it("creates a pattern sentinel with valid config", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "error-watcher", + type: "pattern", + config: { pattern: "error|fail" }, + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.type).toBe("pattern"); + }); + + it("creates an approval sentinel without config", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "needs-approval", + type: "approval", + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.type).toBe("approval"); + expect(result.sentinel.config).toEqual({}); + }); + + it("creates a custom sentinel without config", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "custom-gate", + type: "custom", + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.type).toBe("custom"); + }); + + it("returns error when name is missing", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + type: "webhook", + config: { path: "/hooks/deploy" }, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("name is required"); + }); + + it("returns error for invalid type", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "bad-type", + type: "invalid_type", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("type must be one of"); + }); + + it("returns error for timer config missing durationMs", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "bad-timer", + type: "timer", + config: {}, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("positive durationMs"); + }); + + it("returns error for timer config with negative durationMs", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "neg-timer", + type: "timer", + config: { durationMs: -100 }, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("positive durationMs"); + }); + + it("returns error for threshold config missing metric", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "bad-threshold", + type: "threshold", + config: { operator: "gt", value: 10 }, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("threshold config requires"); + }); + + it("returns error for threshold config with invalid operator", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "bad-op", + type: "threshold", + config: { metric: "calls", operator: "gte", value: 10 }, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("threshold config requires"); + }); + + it("returns error for threshold config missing value", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "no-val", + type: "threshold", + config: { metric: "calls", operator: "gt" }, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("threshold config requires"); + }); + + it("returns error for pattern config missing pattern", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "bad-pattern", + type: "pattern", + config: {}, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("pattern config requires"); + }); + + it("returns error for webhook config missing path", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "bad-webhook", + type: "webhook", + config: {}, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("webhook config requires"); + }); + + it("creates gated_by edges for linkedActionIds", async () => { + const action = (await sdk.trigger("mem::action-create", { + title: "Gated task", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::sentinel-create", { + name: "gate-sentinel", + type: "approval", + linkedActionIds: [action.action.id], + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.linkedActionIds).toEqual([action.action.id]); + + const edges = await kv.list("mem:action-edges"); + const gatedEdges = edges.filter( + (e) => + e.type === "gated_by" && + e.sourceActionId === action.action.id && + e.targetActionId === result.sentinel.id, + ); + expect(gatedEdges.length).toBe(1); + }); + + it("returns error for non-existent linkedActionId", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "bad-link", + type: "approval", + linkedActionIds: ["nonexistent_action"], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("linked action not found"); + }); + + it("sets expiresAt when expiresInMs is provided", async () => { + const result = (await sdk.trigger("mem::sentinel-create", { + name: "expiring", + type: "custom", + expiresInMs: 60000, + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.expiresAt).toBeDefined(); + const created = new Date(result.sentinel.createdAt).getTime(); + const expires = new Date(result.sentinel.expiresAt!).getTime(); + expect(expires - created).toBeCloseTo(60000, -2); + }); + }); + + describe("mem::sentinel-trigger", () => { + it("triggers a watching sentinel", async () => { + const sentinel = (await sdk.trigger("mem::sentinel-create", { + name: "trigger-me", + type: "approval", + })) as { success: boolean; sentinel: Sentinel }; + + const result = (await sdk.trigger("mem::sentinel-trigger", { + sentinelId: sentinel.sentinel.id, + result: { approvedBy: "admin" }, + })) as { success: boolean; sentinel: Sentinel; unblockedCount: number }; + + expect(result.success).toBe(true); + expect(result.sentinel.status).toBe("triggered"); + expect(result.sentinel.triggeredAt).toBeDefined(); + expect(result.sentinel.result).toEqual({ approvedBy: "admin" }); + expect(result.unblockedCount).toBe(0); + }); + + it("returns error when triggering already-triggered sentinel", async () => { + const sentinel = (await sdk.trigger("mem::sentinel-create", { + name: "already-fired", + type: "custom", + })) as { success: boolean; sentinel: Sentinel }; + + await sdk.trigger("mem::sentinel-trigger", { + sentinelId: sentinel.sentinel.id, + }); + + const result = (await sdk.trigger("mem::sentinel-trigger", { + sentinelId: sentinel.sentinel.id, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("already triggered"); + }); + + it("returns error for non-existent sentinel", async () => { + const result = (await sdk.trigger("mem::sentinel-trigger", { + sentinelId: "nonexistent_sentinel", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sentinel not found"); + }); + + it("returns error when sentinelId is missing", async () => { + const result = (await sdk.trigger("mem::sentinel-trigger", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sentinelId is required"); + }); + + it("unblocks gated actions when triggered", async () => { + const action = (await sdk.trigger("mem::action-create", { + title: "Blocked task", + })) as { success: boolean; action: Action }; + + const sentinel = (await sdk.trigger("mem::sentinel-create", { + name: "gate", + type: "approval", + linkedActionIds: [action.action.id], + })) as { success: boolean; sentinel: Sentinel }; + + await sdk.trigger("mem::action-update", { + actionId: action.action.id, + status: "blocked", + }); + + const result = (await sdk.trigger("mem::sentinel-trigger", { + sentinelId: sentinel.sentinel.id, + })) as { success: boolean; sentinel: Sentinel; unblockedCount: number }; + + expect(result.success).toBe(true); + expect(result.unblockedCount).toBe(1); + + const updated = (await sdk.trigger("mem::action-get", { + actionId: action.action.id, + })) as { success: boolean; action: Action }; + + expect(updated.action.status).toBe("pending"); + }); + }); + + describe("mem::sentinel-cancel", () => { + it("cancels a watching sentinel", async () => { + const sentinel = (await sdk.trigger("mem::sentinel-create", { + name: "cancel-me", + type: "custom", + })) as { success: boolean; sentinel: Sentinel }; + + const result = (await sdk.trigger("mem::sentinel-cancel", { + sentinelId: sentinel.sentinel.id, + })) as { success: boolean; sentinel: Sentinel }; + + expect(result.success).toBe(true); + expect(result.sentinel.status).toBe("cancelled"); + }); + + it("returns error when cancelling non-watching sentinel", async () => { + const sentinel = (await sdk.trigger("mem::sentinel-create", { + name: "already-triggered", + type: "custom", + })) as { success: boolean; sentinel: Sentinel }; + + await sdk.trigger("mem::sentinel-trigger", { + sentinelId: sentinel.sentinel.id, + }); + + const result = (await sdk.trigger("mem::sentinel-cancel", { + sentinelId: sentinel.sentinel.id, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("cannot cancel sentinel with status"); + }); + + it("returns error for non-existent sentinel", async () => { + const result = (await sdk.trigger("mem::sentinel-cancel", { + sentinelId: "nonexistent_sentinel", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sentinel not found"); + }); + + it("returns error when sentinelId is missing", async () => { + const result = (await sdk.trigger("mem::sentinel-cancel", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sentinelId is required"); + }); + }); + + describe("mem::sentinel-list", () => { + beforeEach(async () => { + await sdk.trigger("mem::sentinel-create", { + name: "webhook-1", + type: "webhook", + config: { path: "/a" }, + }); + await sdk.trigger("mem::sentinel-create", { + name: "timer-1", + type: "timer", + config: { durationMs: 1000 }, + }); + await sdk.trigger("mem::sentinel-create", { + name: "approval-1", + type: "approval", + }); + }); + + it("returns all sentinels", async () => { + const result = (await sdk.trigger("mem::sentinel-list", {})) as { + success: boolean; + sentinels: Sentinel[]; + }; + + expect(result.success).toBe(true); + expect(result.sentinels.length).toBe(3); + }); + + it("filters by status", async () => { + const all = (await sdk.trigger("mem::sentinel-list", {})) as { + sentinels: Sentinel[]; + }; + await sdk.trigger("mem::sentinel-trigger", { + sentinelId: all.sentinels[0].id, + }); + + const result = (await sdk.trigger("mem::sentinel-list", { + status: "triggered", + })) as { success: boolean; sentinels: Sentinel[] }; + + expect(result.success).toBe(true); + expect(result.sentinels.length).toBe(1); + expect(result.sentinels[0].status).toBe("triggered"); + }); + + it("filters by type", async () => { + const result = (await sdk.trigger("mem::sentinel-list", { + type: "webhook", + })) as { success: boolean; sentinels: Sentinel[] }; + + expect(result.success).toBe(true); + expect(result.sentinels.length).toBe(1); + expect(result.sentinels[0].type).toBe("webhook"); + }); + + it("filters by both status and type", async () => { + const result = (await sdk.trigger("mem::sentinel-list", { + status: "watching", + type: "approval", + })) as { success: boolean; sentinels: Sentinel[] }; + + expect(result.success).toBe(true); + expect(result.sentinels.length).toBe(1); + expect(result.sentinels[0].type).toBe("approval"); + expect(result.sentinels[0].status).toBe("watching"); + }); + }); + + describe("mem::sentinel-expire", () => { + it("expires sentinels past their expiresAt", async () => { + await sdk.trigger("mem::sentinel-create", { + name: "will-expire", + type: "custom", + expiresInMs: 1, + }); + + await new Promise((r) => setTimeout(r, 10)); + + const result = (await sdk.trigger("mem::sentinel-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(1); + + const list = (await sdk.trigger("mem::sentinel-list", { + status: "expired", + })) as { sentinels: Sentinel[] }; + expect(list.sentinels.length).toBe(1); + }); + + it("skips sentinels that have not expired", async () => { + await sdk.trigger("mem::sentinel-create", { + name: "not-expired", + type: "custom", + expiresInMs: 600000, + }); + + const result = (await sdk.trigger("mem::sentinel-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(0); + }); + + it("skips sentinels without expiresAt", async () => { + await sdk.trigger("mem::sentinel-create", { + name: "no-expiry", + type: "custom", + }); + + const result = (await sdk.trigger("mem::sentinel-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(0); + }); + + it("skips non-watching sentinels even if expired", async () => { + const sentinel = (await sdk.trigger("mem::sentinel-create", { + name: "already-cancelled", + type: "custom", + expiresInMs: 1, + })) as { success: boolean; sentinel: Sentinel }; + + await sdk.trigger("mem::sentinel-cancel", { + sentinelId: sentinel.sentinel.id, + }); + + await new Promise((r) => setTimeout(r, 10)); + + const result = (await sdk.trigger("mem::sentinel-expire", {})) as { + success: boolean; + expired: number; + }; + + expect(result.success).toBe(true); + expect(result.expired).toBe(0); + }); + }); + + describe("mem::sentinel-check", () => { + it("triggers threshold sentinel when condition is met", async () => { + await kv.set("mem:metrics", "api_calls", { + totalCalls: 150, + errorCount: 0, + avgDurationMs: 50, + }); + + await sdk.trigger("mem::sentinel-create", { + name: "high-traffic", + type: "threshold", + config: { metric: "api_calls", operator: "gt", value: 100 }, + }); + + const result = (await sdk.trigger("mem::sentinel-check", {})) as { + success: boolean; + triggered: string[]; + checkedCount: number; + }; + + expect(result.success).toBe(true); + expect(result.triggered.length).toBe(1); + expect(result.checkedCount).toBe(1); + }); + + it("does not trigger threshold sentinel when condition is not met", async () => { + await kv.set("mem:metrics", "api_calls", { + totalCalls: 50, + errorCount: 0, + avgDurationMs: 50, + }); + + await sdk.trigger("mem::sentinel-create", { + name: "low-traffic", + type: "threshold", + config: { metric: "api_calls", operator: "gt", value: 100 }, + }); + + const result = (await sdk.trigger("mem::sentinel-check", {})) as { + success: boolean; + triggered: string[]; + checkedCount: number; + }; + + expect(result.success).toBe(true); + expect(result.triggered.length).toBe(0); + }); + + it("returns empty triggered list when no active sentinels", async () => { + const result = (await sdk.trigger("mem::sentinel-check", {})) as { + success: boolean; + triggered: string[]; + checkedCount: number; + }; + + expect(result.success).toBe(true); + expect(result.triggered).toEqual([]); + expect(result.checkedCount).toBe(0); + }); + }); +}); diff --git a/test/session-end-triggers-graph.test.ts b/test/session-end-triggers-graph.test.ts new file mode 100644 index 0000000..22a7804 --- /dev/null +++ b/test/session-end-triggers-graph.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// #666: api::session::end must publish the session-stopped lifecycle so +// summarize + slot-reflect + graph extraction actually fire. Before this +// fix the `event::session::stopped` handler in events.ts was a dead +// subscriber — no code published `agentmemory.session.stopped`, so graph +// nodes / lessons / crystals never materialized despite the handler +// existing. Direct fire-and-forget trigger keeps the HTTP response fast +// (kv.update runs synchronously, downstream pipeline fan-outs without +// blocking). +describe("api::session::end → event::session::stopped (#666)", () => { + const api = readFileSync("src/triggers/api.ts", "utf-8"); + + it("api::session::end fires event::session::stopped after kv.update", () => { + expect(api).toMatch( + /api::session::end[\s\S]*?kv\.update\(KV\.sessions[\s\S]*?function_id:\s*"event::session::stopped"/, + ); + }); + + it("event::session::stopped trigger payload includes sessionId", () => { + expect(api).toMatch( + /function_id:\s*"event::session::stopped",\s*payload:\s*\{\s*sessionId\s*\}/, + ); + }); + + it("event::session::stopped uses TriggerAction.Void for fire-and-forget", () => { + expect(api).toMatch( + /function_id:\s*"event::session::stopped"[\s\S]*?action:\s*TriggerAction\.Void\(\)/, + ); + }); +}); + +// #666: viewer's "Build Graph" button used to POST /agentmemory/graph/build +// which returned 404 because the endpoint was never registered. Backfill +// the knowledge graph from existing compressed observations across every +// session in batches. +describe("api::graph-build endpoint (#666)", () => { + const api = readFileSync("src/triggers/api.ts", "utf-8"); + + it("registers api::graph-build function", () => { + expect(api).toMatch(/registerFunction\("api::graph-build"/); + }); + + it("registers HTTP trigger at /agentmemory/graph/build", () => { + expect(api).toMatch( + /api_path:\s*"\/agentmemory\/graph\/build",\s*http_method:\s*"POST"/, + ); + }); + + it("iterates sessions and calls mem::graph-extract", () => { + expect(api).toMatch(/kv\.list\(KV\.sessions\)/); + expect(api).toMatch(/kv\.list\(KV\.observations\(sid\)\)/); + expect(api).toMatch( + /sdk\.trigger\(\{\s*function_id:\s*"mem::graph-extract"/, + ); + }); + + it("filters observations that have a title (compressed only)", () => { + expect(api).toMatch(/typeof o\.title === "string" && o\.title\.length > 0/); + }); + + it("respects batchSize override with a 100-item upper bound", () => { + expect(api).toMatch(/Math\.min\(100,\s*Number\(.*batchSize/); + }); + + it("response shape matches what the viewer expects (success + nodes)", () => { + expect(api).toMatch(/success:\s*true,\s*sessions:[\s\S]*?nodes:\s*totalNodes/); + }); +}); + +// #666: `agentmemory status` showed Memories/Observations as 0 because it +// fetched /agentmemory/export which times out on iii-engine's file-based +// KV under concurrent kv.list() pressure. Switch to /memories for the +// memory count and derive observation count from sessions[].observationCount. +describe("agentmemory status no longer depends on /export (#666)", () => { + const cli = readFileSync("src/cli.ts", "utf-8"); + + it("status uses count-only memories endpoint instead of export", () => { + expect(cli).toMatch(/apiFetch\(base,\s*"memories\?count=true"\)/); + expect(cli).not.toMatch(/apiFetch\(base,\s*"export"\)/); + }); + + it("status derives obsCount from sessions[].observationCount", () => { + expect(cli).toMatch( + /sessionList\.reduce\([\s\S]*?observationCount/, + ); + }); + + it("status reads memCount from memoriesRes.latestCount (count endpoint)", () => { + expect(cli).toMatch(/memoriesRes\?\.latestCount\s*\?\?\s*memoriesRes\?\.total/); + }); +}); diff --git a/test/signals.test.ts b/test/signals.test.ts new file mode 100644 index 0000000..3554a7f --- /dev/null +++ b/test/signals.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerSignalsFunction } from "../src/functions/signals.js"; +import type { Signal } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Signals Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerSignalsFunction(sdk as never, kv as never); + }); + + describe("mem::signal-send", () => { + it("sends a signal with valid data", async () => { + const result = (await sdk.trigger("mem::signal-send", { + from: "agent-a", + to: "agent-b", + content: "Hello there", + type: "info", + })) as { success: boolean; signal: Signal }; + + expect(result.success).toBe(true); + expect(result.signal.id).toMatch(/^sig_/); + expect(result.signal.from).toBe("agent-a"); + expect(result.signal.to).toBe("agent-b"); + expect(result.signal.content).toBe("Hello there"); + expect(result.signal.type).toBe("info"); + expect(result.signal.threadId).toMatch(/^thr_/); + expect(result.signal.createdAt).toBeDefined(); + }); + + it("returns error when from is missing", async () => { + const result = (await sdk.trigger("mem::signal-send", { + from: "", + content: "Hello", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("from and non-empty content are required"); + }); + + it("returns error when content is whitespace only", async () => { + const result = (await sdk.trigger("mem::signal-send", { + from: "agent-a", + content: " ", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("from and non-empty content are required"); + }); + + it("returns error when content is empty string", async () => { + const result = (await sdk.trigger("mem::signal-send", { + from: "agent-a", + content: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("from and non-empty content are required"); + }); + + it("auto-threads replies from parent signal", async () => { + const parent = (await sdk.trigger("mem::signal-send", { + from: "agent-a", + to: "agent-b", + content: "Initial message", + })) as { success: boolean; signal: Signal }; + + const reply = (await sdk.trigger("mem::signal-send", { + from: "agent-b", + to: "agent-a", + content: "Reply message", + replyTo: parent.signal.id, + })) as { success: boolean; signal: Signal }; + + expect(reply.success).toBe(true); + expect(reply.signal.threadId).toBe(parent.signal.threadId); + expect(reply.signal.replyTo).toBe(parent.signal.id); + }); + + it("sets expiresAt when expiresInMs is provided", async () => { + const result = (await sdk.trigger("mem::signal-send", { + from: "agent-a", + content: "Temporary message", + expiresInMs: 60000, + })) as { success: boolean; signal: Signal }; + + expect(result.success).toBe(true); + expect(result.signal.expiresAt).toBeDefined(); + const expiresAt = new Date(result.signal.expiresAt!).getTime(); + const createdAt = new Date(result.signal.createdAt).getTime(); + expect(expiresAt - createdAt).toBeCloseTo(60000, -2); + }); + + it("defaults type to info when not specified", async () => { + const result = (await sdk.trigger("mem::signal-send", { + from: "agent-a", + content: "No type specified", + })) as { success: boolean; signal: Signal }; + + expect(result.success).toBe(true); + expect(result.signal.type).toBe("info"); + }); + + it("trims content whitespace", async () => { + const result = (await sdk.trigger("mem::signal-send", { + from: "agent-a", + content: " padded content ", + })) as { success: boolean; signal: Signal }; + + expect(result.success).toBe(true); + expect(result.signal.content).toBe("padded content"); + }); + }); + + describe("mem::signal-read", () => { + beforeEach(async () => { + await sdk.trigger("mem::signal-send", { + from: "agent-a", + to: "agent-b", + content: "Message 1", + type: "info", + }); + await sdk.trigger("mem::signal-send", { + from: "agent-c", + to: "agent-b", + content: "Message 2", + type: "request", + }); + await sdk.trigger("mem::signal-send", { + from: "agent-b", + to: "agent-a", + content: "Message 3", + type: "response", + }); + }); + + it("reads signals for an agent", async () => { + const result = (await sdk.trigger("mem::signal-read", { + agentId: "agent-b", + })) as { success: boolean; signals: Signal[] }; + + expect(result.success).toBe(true); + expect(result.signals.length).toBeGreaterThanOrEqual(2); + }); + + it("marks signals as read", async () => { + await sdk.trigger("mem::signal-read", { + agentId: "agent-b", + }); + + const signals = await kv.list("mem:signals"); + const toAgentB = signals.filter((s) => s.to === "agent-b"); + expect(toAgentB.every((s) => s.readAt !== undefined)).toBe(true); + }); + + it("filters by unreadOnly", async () => { + await sdk.trigger("mem::signal-read", { + agentId: "agent-b", + }); + + const result = (await sdk.trigger("mem::signal-read", { + agentId: "agent-b", + unreadOnly: true, + })) as { success: boolean; signals: Signal[] }; + + expect(result.success).toBe(true); + expect(result.signals.length).toBe(0); + }); + + it("filters by threadId", async () => { + const sent = (await sdk.trigger("mem::signal-send", { + from: "agent-x", + to: "agent-b", + content: "Thread-specific message", + threadId: "thr_specific", + })) as { success: boolean; signal: Signal }; + + const result = (await sdk.trigger("mem::signal-read", { + agentId: "agent-b", + threadId: "thr_specific", + })) as { success: boolean; signals: Signal[] }; + + expect(result.success).toBe(true); + expect(result.signals.length).toBe(1); + expect(result.signals[0].threadId).toBe("thr_specific"); + }); + + it("filters by type", async () => { + const result = (await sdk.trigger("mem::signal-read", { + agentId: "agent-b", + type: "request", + })) as { success: boolean; signals: Signal[] }; + + expect(result.success).toBe(true); + expect(result.signals.every((s) => s.type === "request")).toBe(true); + }); + + it("returns error when agentId is missing", async () => { + const result = (await sdk.trigger("mem::signal-read", { + agentId: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("agentId is required"); + }); + }); + + describe("mem::signal-threads", () => { + it("groups signals by thread", async () => { + const first = (await sdk.trigger("mem::signal-send", { + from: "agent-a", + to: "agent-b", + content: "Thread 1 message 1", + })) as { success: boolean; signal: Signal }; + + await sdk.trigger("mem::signal-send", { + from: "agent-b", + to: "agent-a", + content: "Thread 1 message 2", + replyTo: first.signal.id, + }); + + await sdk.trigger("mem::signal-send", { + from: "agent-a", + to: "agent-b", + content: "Different thread", + }); + + const result = (await sdk.trigger("mem::signal-threads", { + agentId: "agent-a", + })) as { + success: boolean; + threads: Array<{ + threadId: string; + messages: number; + participants: string[]; + }>; + }; + + expect(result.success).toBe(true); + expect(result.threads.length).toBe(2); + + const firstThread = result.threads.find( + (t) => t.threadId === first.signal.threadId, + ); + expect(firstThread).toBeDefined(); + expect(firstThread!.messages).toBe(2); + expect(firstThread!.participants).toContain("agent-a"); + expect(firstThread!.participants).toContain("agent-b"); + }); + + it("returns error when agentId is missing", async () => { + const result = (await sdk.trigger("mem::signal-threads", { + agentId: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("agentId is required"); + }); + }); + + describe("mem::signal-cleanup", () => { + it("removes expired signals", async () => { + const now = Date.now(); + const expiredSignal: Signal = { + id: "sig_expired", + from: "agent-a", + to: "agent-b", + content: "Expired", + type: "info", + threadId: "thr_1", + createdAt: new Date(now - 120000).toISOString(), + expiresAt: new Date(now - 60000).toISOString(), + }; + await kv.set("mem:signals", expiredSignal.id, expiredSignal); + + const validSignal: Signal = { + id: "sig_valid", + from: "agent-a", + to: "agent-b", + content: "Still valid", + type: "info", + threadId: "thr_2", + createdAt: new Date(now).toISOString(), + expiresAt: new Date(now + 60000).toISOString(), + }; + await kv.set("mem:signals", validSignal.id, validSignal); + + const result = (await sdk.trigger("mem::signal-cleanup", {})) as { + success: boolean; + removed: number; + }; + + expect(result.success).toBe(true); + expect(result.removed).toBe(1); + + const remaining = await kv.list("mem:signals"); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("sig_valid"); + }); + + it("keeps signals without expiration", async () => { + const noExpiry: Signal = { + id: "sig_noexpiry", + from: "agent-a", + content: "No expiration", + type: "info", + threadId: "thr_3", + createdAt: new Date().toISOString(), + }; + await kv.set("mem:signals", noExpiry.id, noExpiry); + + const result = (await sdk.trigger("mem::signal-cleanup", {})) as { + success: boolean; + removed: number; + }; + + expect(result.success).toBe(true); + expect(result.removed).toBe(0); + + const remaining = await kv.list("mem:signals"); + expect(remaining.length).toBe(1); + }); + + it("removes multiple expired signals at once", async () => { + const now = Date.now(); + + for (let i = 0; i < 5; i++) { + const sig: Signal = { + id: `sig_exp_${i}`, + from: "agent-a", + content: `Expired ${i}`, + type: "info", + threadId: `thr_${i}`, + createdAt: new Date(now - 200000).toISOString(), + expiresAt: new Date(now - 100000).toISOString(), + }; + await kv.set("mem:signals", sig.id, sig); + } + + const keepSig: Signal = { + id: "sig_keep", + from: "agent-b", + content: "Keep me", + type: "alert", + threadId: "thr_keep", + createdAt: new Date(now).toISOString(), + }; + await kv.set("mem:signals", keepSig.id, keepSig); + + const result = (await sdk.trigger("mem::signal-cleanup", {})) as { + success: boolean; + removed: number; + }; + + expect(result.success).toBe(true); + expect(result.removed).toBe(5); + + const remaining = await kv.list("mem:signals"); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe("sig_keep"); + }); + }); +}); diff --git a/test/sketches.test.ts b/test/sketches.test.ts new file mode 100644 index 0000000..ce72b62 --- /dev/null +++ b/test/sketches.test.ts @@ -0,0 +1,550 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerSketchesFunction } from "../src/functions/sketches.js"; +import type { Action, ActionEdge, Sketch } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Sketches Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + registerSketchesFunction(sdk as never, kv as never); + }); + + describe("mem::sketch-create", () => { + it("creates a sketch with valid title", async () => { + const result = (await sdk.trigger("mem::sketch-create", { + title: "Refactor auth module", + })) as { success: boolean; sketch: Sketch }; + + expect(result.success).toBe(true); + expect(result.sketch.id).toMatch(/^sk_/); + expect(result.sketch.title).toBe("Refactor auth module"); + expect(result.sketch.status).toBe("active"); + expect(result.sketch.actionIds).toEqual([]); + expect(result.sketch.createdAt).toBeDefined(); + expect(result.sketch.expiresAt).toBeDefined(); + }); + + it("returns error when title is empty", async () => { + const result = (await sdk.trigger("mem::sketch-create", { + title: "", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("title is required"); + }); + + it("returns error when title is missing", async () => { + const result = (await sdk.trigger("mem::sketch-create", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("title is required"); + }); + + it("creates a sketch with custom TTL", async () => { + const result = (await sdk.trigger("mem::sketch-create", { + title: "Short-lived sketch", + expiresInMs: 60000, + })) as { success: boolean; sketch: Sketch }; + + expect(result.success).toBe(true); + const created = new Date(result.sketch.createdAt).getTime(); + const expires = new Date(result.sketch.expiresAt).getTime(); + expect(expires - created).toBe(60000); + }); + + it("defaults TTL to one hour", async () => { + const result = (await sdk.trigger("mem::sketch-create", { + title: "Default TTL", + })) as { success: boolean; sketch: Sketch }; + + expect(result.success).toBe(true); + const created = new Date(result.sketch.createdAt).getTime(); + const expires = new Date(result.sketch.expiresAt).getTime(); + expect(expires - created).toBe(3600000); + }); + + it("stores project on sketch", async () => { + const result = (await sdk.trigger("mem::sketch-create", { + title: "Project sketch", + project: "webapp", + })) as { success: boolean; sketch: Sketch }; + + expect(result.success).toBe(true); + expect(result.sketch.project).toBe("webapp"); + }); + }); + + describe("mem::sketch-add", () => { + let sketchId: string; + + beforeEach(async () => { + const result = (await sdk.trigger("mem::sketch-create", { + title: "Test sketch", + project: "myproject", + })) as { success: boolean; sketch: Sketch }; + sketchId = result.sketch.id; + }); + + it("adds an action to the sketch", async () => { + const result = (await sdk.trigger("mem::sketch-add", { + sketchId, + title: "Implement login", + description: "Add SSO support", + priority: 8, + })) as { success: boolean; action: Action; edges: ActionEdge[] }; + + expect(result.success).toBe(true); + expect(result.action.id).toMatch(/^act_/); + expect(result.action.title).toBe("Implement login"); + expect(result.action.description).toBe("Add SSO support"); + expect(result.action.priority).toBe(8); + expect(result.action.sketchId).toBe(sketchId); + expect(result.action.project).toBe("myproject"); + expect(result.action.createdBy).toBe("sketch"); + expect(result.edges).toEqual([]); + }); + + it("returns error for non-existent sketch", async () => { + const result = (await sdk.trigger("mem::sketch-add", { + sketchId: "nonexistent", + title: "Some action", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sketch not found"); + }); + + it("returns error for non-active sketch", async () => { + await sdk.trigger("mem::sketch-promote", { sketchId }); + + const result = (await sdk.trigger("mem::sketch-add", { + sketchId, + title: "Late action", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sketch is not active"); + }); + + it("adds action with dependsOn within sketch", async () => { + const first = (await sdk.trigger("mem::sketch-add", { + sketchId, + title: "First step", + })) as { success: boolean; action: Action }; + + const second = (await sdk.trigger("mem::sketch-add", { + sketchId, + title: "Second step", + dependsOn: [first.action.id], + })) as { success: boolean; action: Action; edges: ActionEdge[] }; + + expect(second.success).toBe(true); + expect(second.edges.length).toBe(1); + expect(second.edges[0].type).toBe("requires"); + expect(second.edges[0].sourceActionId).toBe(second.action.id); + expect(second.edges[0].targetActionId).toBe(first.action.id); + }); + + it("returns error for dependsOn referencing action outside sketch", async () => { + const result = (await sdk.trigger("mem::sketch-add", { + sketchId, + title: "Depends on external", + dependsOn: ["act_outside"], + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("not found in this sketch"); + }); + + it("returns error when sketchId is missing", async () => { + const result = (await sdk.trigger("mem::sketch-add", { + title: "No sketch", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sketchId is required"); + }); + + it("returns error when title is missing", async () => { + const result = (await sdk.trigger("mem::sketch-add", { + sketchId, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("title is required"); + }); + }); + + describe("mem::sketch-promote", () => { + it("promotes sketch and removes sketchId from actions", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "Promotable sketch", + project: "alpha", + })) as { success: boolean; sketch: Sketch }; + + const a1 = (await sdk.trigger("mem::sketch-add", { + sketchId: sketch.sketch.id, + title: "Action 1", + })) as { success: boolean; action: Action }; + + const a2 = (await sdk.trigger("mem::sketch-add", { + sketchId: sketch.sketch.id, + title: "Action 2", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::sketch-promote", { + sketchId: sketch.sketch.id, + })) as { success: boolean; promotedIds: string[] }; + + expect(result.success).toBe(true); + expect(result.promotedIds).toContain(a1.action.id); + expect(result.promotedIds).toContain(a2.action.id); + expect(result.promotedIds.length).toBe(2); + + const stored = await kv.get("mem:actions", a1.action.id); + expect(stored!.sketchId).toBeUndefined(); + expect(stored!.project).toBe("alpha"); + }); + + it("promotes sketch with project override", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "Override project", + project: "original", + })) as { success: boolean; sketch: Sketch }; + + const a = (await sdk.trigger("mem::sketch-add", { + sketchId: sketch.sketch.id, + title: "Task", + })) as { success: boolean; action: Action }; + + const result = (await sdk.trigger("mem::sketch-promote", { + sketchId: sketch.sketch.id, + project: "newproject", + })) as { success: boolean; promotedIds: string[] }; + + expect(result.success).toBe(true); + + const stored = await kv.get("mem:actions", a.action.id); + expect(stored!.project).toBe("newproject"); + expect(stored!.sketchId).toBeUndefined(); + }); + + it("returns error for non-active sketch", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "To be discarded", + })) as { success: boolean; sketch: Sketch }; + + await sdk.trigger("mem::sketch-discard", { + sketchId: sketch.sketch.id, + }); + + const result = (await sdk.trigger("mem::sketch-promote", { + sketchId: sketch.sketch.id, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sketch is not active"); + }); + + it("returns error for non-existent sketch", async () => { + const result = (await sdk.trigger("mem::sketch-promote", { + sketchId: "nonexistent", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sketch not found"); + }); + + it("sets sketch status to promoted", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "Promote me", + })) as { success: boolean; sketch: Sketch }; + + await sdk.trigger("mem::sketch-promote", { + sketchId: sketch.sketch.id, + }); + + const stored = await kv.get("mem:sketches", sketch.sketch.id); + expect(stored!.status).toBe("promoted"); + expect(stored!.promotedAt).toBeDefined(); + }); + }); + + describe("mem::sketch-discard", () => { + it("discards sketch and deletes actions and edges", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "Discard me", + })) as { success: boolean; sketch: Sketch }; + + const a1 = (await sdk.trigger("mem::sketch-add", { + sketchId: sketch.sketch.id, + title: "Action 1", + })) as { success: boolean; action: Action }; + + const a2 = (await sdk.trigger("mem::sketch-add", { + sketchId: sketch.sketch.id, + title: "Action 2", + dependsOn: [a1.action.id], + })) as { success: boolean; action: Action; edges: ActionEdge[] }; + + const result = (await sdk.trigger("mem::sketch-discard", { + sketchId: sketch.sketch.id, + })) as { success: boolean; discardedCount: number }; + + expect(result.success).toBe(true); + expect(result.discardedCount).toBe(2); + + const storedA1 = await kv.get("mem:actions", a1.action.id); + expect(storedA1).toBeNull(); + + const storedA2 = await kv.get("mem:actions", a2.action.id); + expect(storedA2).toBeNull(); + + const storedEdge = await kv.get( + "mem:action-edges", + a2.edges[0].id, + ); + expect(storedEdge).toBeNull(); + + const storedSketch = await kv.get( + "mem:sketches", + sketch.sketch.id, + ); + expect(storedSketch!.status).toBe("discarded"); + expect(storedSketch!.discardedAt).toBeDefined(); + }); + + it("returns error for non-active sketch", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "Promote first", + })) as { success: boolean; sketch: Sketch }; + + await sdk.trigger("mem::sketch-promote", { + sketchId: sketch.sketch.id, + }); + + const result = (await sdk.trigger("mem::sketch-discard", { + sketchId: sketch.sketch.id, + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sketch is not active"); + }); + + it("returns error for non-existent sketch", async () => { + const result = (await sdk.trigger("mem::sketch-discard", { + sketchId: "nonexistent", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("sketch not found"); + }); + }); + + describe("mem::sketch-list", () => { + beforeEach(async () => { + await sdk.trigger("mem::sketch-create", { + title: "Active alpha", + project: "alpha", + }); + await new Promise((r) => setTimeout(r, 5)); + await sdk.trigger("mem::sketch-create", { + title: "Active beta", + project: "beta", + }); + await new Promise((r) => setTimeout(r, 5)); + const toPromote = (await sdk.trigger("mem::sketch-create", { + title: "Promoted alpha", + project: "alpha", + })) as { success: boolean; sketch: Sketch }; + await sdk.trigger("mem::sketch-promote", { + sketchId: toPromote.sketch.id, + }); + }); + + it("returns all sketches", async () => { + const result = (await sdk.trigger("mem::sketch-list", {})) as { + success: boolean; + sketches: (Sketch & { actionCount: number })[]; + }; + + expect(result.success).toBe(true); + expect(result.sketches.length).toBe(3); + }); + + it("filters by status", async () => { + const result = (await sdk.trigger("mem::sketch-list", { + status: "active", + })) as { + success: boolean; + sketches: (Sketch & { actionCount: number })[]; + }; + + expect(result.success).toBe(true); + expect(result.sketches.length).toBe(2); + expect(result.sketches.every((s) => s.status === "active")).toBe(true); + }); + + it("filters by project", async () => { + const result = (await sdk.trigger("mem::sketch-list", { + project: "alpha", + })) as { + success: boolean; + sketches: (Sketch & { actionCount: number })[]; + }; + + expect(result.success).toBe(true); + expect(result.sketches.length).toBe(2); + expect(result.sketches.every((s) => s.project === "alpha")).toBe(true); + }); + + it("includes actionCount in results", async () => { + const result = (await sdk.trigger("mem::sketch-list", {})) as { + success: boolean; + sketches: (Sketch & { actionCount: number })[]; + }; + + expect(result.success).toBe(true); + expect(result.sketches[0].actionCount).toBe(0); + }); + }); + + describe("mem::sketch-gc", () => { + it("collects expired active sketches", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "Expired sketch", + expiresInMs: 1, + })) as { success: boolean; sketch: Sketch }; + + await sdk.trigger("mem::sketch-add", { + sketchId: sketch.sketch.id, + title: "Doomed action", + }); + + await new Promise((r) => setTimeout(r, 10)); + + const result = (await sdk.trigger("mem::sketch-gc", {})) as { + success: boolean; + collected: number; + }; + + expect(result.success).toBe(true); + expect(result.collected).toBe(1); + + const stored = await kv.get("mem:sketches", sketch.sketch.id); + expect(stored!.status).toBe("discarded"); + expect(stored!.discardedAt).toBeDefined(); + }); + + it("skips non-expired sketches", async () => { + await sdk.trigger("mem::sketch-create", { + title: "Still alive", + expiresInMs: 3600000, + }); + + const result = (await sdk.trigger("mem::sketch-gc", {})) as { + success: boolean; + collected: number; + }; + + expect(result.success).toBe(true); + expect(result.collected).toBe(0); + }); + + it("skips non-active sketches", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "Already promoted", + expiresInMs: 1, + })) as { success: boolean; sketch: Sketch }; + + await sdk.trigger("mem::sketch-promote", { + sketchId: sketch.sketch.id, + }); + + await new Promise((r) => setTimeout(r, 10)); + + const result = (await sdk.trigger("mem::sketch-gc", {})) as { + success: boolean; + collected: number; + }; + + expect(result.success).toBe(true); + expect(result.collected).toBe(0); + }); + + it("deletes actions and edges of expired sketches", async () => { + const sketch = (await sdk.trigger("mem::sketch-create", { + title: "GC with cleanup", + expiresInMs: 1, + })) as { success: boolean; sketch: Sketch }; + + const a1 = (await sdk.trigger("mem::sketch-add", { + sketchId: sketch.sketch.id, + title: "Step 1", + })) as { success: boolean; action: Action }; + + await sdk.trigger("mem::sketch-add", { + sketchId: sketch.sketch.id, + title: "Step 2", + dependsOn: [a1.action.id], + }); + + await new Promise((r) => setTimeout(r, 10)); + + await sdk.trigger("mem::sketch-gc", {}); + + const storedAction = await kv.get("mem:actions", a1.action.id); + expect(storedAction).toBeNull(); + }); + }); +}); diff --git a/test/skill-extract.test.ts b/test/skill-extract.test.ts new file mode 100644 index 0000000..b28fce6 --- /dev/null +++ b/test/skill-extract.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockKv = { + get: vi.fn(), + set: vi.fn(), + list: vi.fn(), + delete: vi.fn(), +}; + +const mockSdk = { + registerFunction: vi.fn(), +}; + +const mockProvider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), +}; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/functions/audit.js", () => ({ + recordAudit: vi.fn(), +})); + +import { registerSkillExtractFunctions } from "../src/functions/skill-extract.js"; + +describe("skill-extract", () => { + let handlers: Record; + + beforeEach(() => { + vi.clearAllMocks(); + mockKv.get.mockResolvedValue(null); + mockKv.set.mockResolvedValue(undefined); + mockKv.list.mockResolvedValue([]); + + handlers = {}; + mockSdk.registerFunction.mockImplementation((idOrMeta: any, handler: any) => { + const id = typeof idOrMeta === "string" ? idOrMeta : idOrMeta.id; + handlers[id] = handler; + }); + + registerSkillExtractFunctions(mockSdk as any, mockKv as any, mockProvider); + }); + + it("registers all skill functions", () => { + expect(handlers["mem::skill-extract"]).toBeDefined(); + expect(handlers["mem::skill-list"]).toBeDefined(); + expect(handlers["mem::skill-match"]).toBeDefined(); + }); + + it("skill-extract requires sessionId", async () => { + const result = await handlers["mem::skill-extract"]({}); + expect(result.success).toBe(false); + }); + + it("skill-extract returns error for missing session", async () => { + mockKv.get.mockReturnValue(Promise.resolve(null)); + const result = await handlers["mem::skill-extract"]({ + sessionId: "nonexistent", + }); + expect(result.success).toBe(false); + expect(result.error).toContain("session not found"); + }); + + it("skill-extract parses LLM response into ProceduralMemory", async () => { + mockKv.get.mockImplementation((scope: string, key: string) => { + if (scope === "mem:sessions") + return Promise.resolve({ id: "s1", project: "test", status: "completed" }); + if (scope === "mem:summaries") + return Promise.resolve({ + sessionId: "s1", + project: "test", + title: "Fix auth bug", + narrative: "Debugged and fixed JWT expiration", + keyDecisions: ["Switch to RS256"], + filesModified: ["auth.ts"], + concepts: ["authentication", "JWT"], + createdAt: new Date().toISOString(), + observationCount: 10, + }); + return Promise.resolve(null); + }); + + mockKv.list.mockReturnValue( + Promise.resolve(Array.from({ length: 5 }, (_, i) => ({ + id: `obs${i}`, + sessionId: "s1", + timestamp: new Date().toISOString(), + type: "file_edit", + title: `Edit auth.ts step ${i}`, + narrative: `Modified JWT validation logic step ${i}`, + importance: 7, + concepts: ["JWT"], + files: ["auth.ts"], + facts: [], + }))), + ); + + mockProvider.summarize.mockResolvedValue(` + +When the agent encounters JWT authentication failures or token expiration issues +Fix JWT Token Expiration + +Check the JWT library configuration for clock skew tolerance +Verify the signing algorithm matches between issuer and verifier +Update token expiration to use RS256 with proper key rotation + +JWT auth works reliably with proper expiration handling +jwt,authentication,security + + `); + + const result = await handlers["mem::skill-extract"]({ sessionId: "s1" }); + expect(result.success).toBe(true); + expect(result.extracted).toBe(true); + expect(result.skill.name).toBe("Fix JWT Token Expiration"); + expect(result.skill.steps).toHaveLength(3); + expect(result.skill.triggerCondition).toContain("JWT"); + expect(mockKv.set).toHaveBeenCalled(); + }); + + it("skill-extract returns no-skill for exploratory sessions", async () => { + mockKv.get.mockImplementation((scope: string) => { + if (scope === "mem:sessions") return Promise.resolve({ id: "s1", project: "test", status: "completed" }); + if (scope === "mem:summaries") + return Promise.resolve({ + sessionId: "s1", + project: "test", + title: "Explore codebase", + narrative: "Browsed files", + keyDecisions: [], + filesModified: [], + concepts: [], + createdAt: new Date().toISOString(), + observationCount: 3, + }); + return Promise.resolve(null); + }); + mockKv.list.mockReturnValue( + Promise.resolve(Array.from({ length: 4 }, (_, i) => ({ + id: `obs${i}`, + sessionId: "s1", + timestamp: new Date().toISOString(), + type: "file_read", + title: `Read file ${i}`, + importance: 5, + concepts: [], + files: [], + facts: [], + narrative: "", + }))), + ); + mockProvider.summarize.mockResolvedValue(""); + + const result = await handlers["mem::skill-extract"]({ sessionId: "s1" }); + expect(result.success).toBe(true); + expect(result.extracted).toBe(false); + }); + + it("skill-list returns sorted by strength", async () => { + mockKv.list.mockResolvedValue([ + { id: "s1", name: "Low", strength: 0.3 }, + { id: "s2", name: "High", strength: 0.9 }, + ]); + const result = await handlers["mem::skill-list"]({}); + expect(result.skills[0].name).toBe("High"); + }); + + it("skill-match finds relevant skills", async () => { + mockKv.list.mockResolvedValue([ + { + id: "s1", + name: "Fix JWT Auth", + triggerCondition: "JWT failures", + tags: ["jwt", "auth"], + steps: ["check config", "update key"], + strength: 0.8, + }, + { + id: "s2", + name: "Deploy Docker", + triggerCondition: "container deployment", + tags: ["docker", "deploy"], + steps: ["build image", "push"], + strength: 0.7, + }, + ]); + + const result = await handlers["mem::skill-match"]({ + query: "JWT authentication token expired", + }); + expect(result.matches.length).toBe(1); + expect(result.matches[0].skill.name).toBe("Fix JWT Auth"); + }); +}); diff --git a/test/sliding-window.test.ts b/test/sliding-window.test.ts new file mode 100644 index 0000000..efb430d --- /dev/null +++ b/test/sliding-window.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { CompressedObservation, MemoryProvider } from "../src/types.js"; + +function makeObs( + id: string, + title: string, + narrative: string, + overrides: Partial = {}, +): CompressedObservation { + return { + id, + sessionId: "ses_1", + timestamp: new Date().toISOString(), + type: "file_edit", + title, + subtitle: "", + facts: [], + narrative, + concepts: [], + files: [], + importance: 5, + ...overrides, + }; +} + +function mockKV(observations: CompressedObservation[] = []) { + const store = new Map>(); + const obsMap = new Map(); + for (const obs of observations) { + obsMap.set(obs.id, obs); + } + store.set("mem:obs:ses_1", obsMap); + + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, fn); + }, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +function mockProvider(response: string): MemoryProvider { + return { + name: "test", + compress: vi.fn().mockResolvedValue(response), + summarize: vi.fn().mockResolvedValue(response), + }; +} + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +describe("SlidingWindow", () => { + it("imports without errors", async () => { + const mod = await import("../src/functions/sliding-window.js"); + expect(mod.registerSlidingWindowFunction).toBeDefined(); + }); + + it("registers both functions", async () => { + const { registerSlidingWindowFunction } = await import( + "../src/functions/sliding-window.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + const provider = mockProvider(""); + registerSlidingWindowFunction(sdk as never, kv as never, provider); + + expect(sdk.trigger).toBeDefined(); + }); + + it("enriches observation with sliding window context", async () => { + const { registerSlidingWindowFunction } = await import( + "../src/functions/sliding-window.js" + ); + + const obs1 = makeObs( + "obs_1", + "User discussed React framework", + "The user mentioned they are working with React for their frontend.", + { timestamp: "2024-01-01T00:00:00Z" }, + ); + const obs2 = makeObs( + "obs_2", + "Framework frustration", + "The user said they hate that framework and find it hard to debug.", + { timestamp: "2024-01-01T00:01:00Z" }, + ); + const obs3 = makeObs( + "obs_3", + "Switching to Vue", + "The user decided to switch to Vue for the project.", + { timestamp: "2024-01-01T00:02:00Z" }, + ); + + const kv = mockKV([obs1, obs2, obs3]); + const sdk = mockSdk(); + + const enrichedXml = ` + The user (working with React for frontend) expressed strong frustration with React framework, finding it difficult to debug. + + + + + User dislikes React due to debugging difficulty + + + User was working with React before expressing frustration + +`; + + const provider = mockProvider(enrichedXml); + registerSlidingWindowFunction(sdk as never, kv as never, provider); + + const result = (await sdk.trigger("mem::enrich-window", { + observationId: "obs_2", + sessionId: "ses_1", + lookback: 1, + lookahead: 1, + })) as { success: boolean; enriched: any }; + + expect(result.success).toBe(true); + expect(result.enriched).toBeDefined(); + expect(result.enriched.resolvedEntities["that framework"]).toBe("React"); + expect(result.enriched.preferences).toContain( + "User dislikes React due to debugging difficulty", + ); + expect(result.enriched.contextBridges.length).toBeGreaterThan(0); + }); + + it("returns null enrichment when no adjacent observations", async () => { + const { registerSlidingWindowFunction } = await import( + "../src/functions/sliding-window.js" + ); + const obs = makeObs("obs_solo", "Solo observation", "Just one."); + const kv = mockKV([obs]); + const sdk = mockSdk(); + const provider = mockProvider(""); + registerSlidingWindowFunction(sdk as never, kv as never, provider); + + const result = (await sdk.trigger("mem::enrich-window", { + observationId: "obs_solo", + sessionId: "ses_1", + })) as { success: boolean; enriched: any; reason: string }; + + expect(result.success).toBe(true); + expect(result.enriched).toBeNull(); + }); + + it("returns error for missing observation", async () => { + const { registerSlidingWindowFunction } = await import( + "../src/functions/sliding-window.js" + ); + const kv = mockKV([]); + const sdk = mockSdk(); + const provider = mockProvider(""); + registerSlidingWindowFunction(sdk as never, kv as never, provider); + + const result = (await sdk.trigger("mem::enrich-window", { + observationId: "nonexistent", + sessionId: "ses_1", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toBe("Observation not found"); + }); +}); diff --git a/test/slots-flag-gate.test.ts b/test/slots-flag-gate.test.ts new file mode 100644 index 0000000..287bcbd --- /dev/null +++ b/test/slots-flag-gate.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// Regression tests for #678: +// - isSlotsEnabled / isReflectEnabled must read from ~/.agentmemory/.env +// (not only process.env), so users who set AGENTMEMORY_SLOTS in the +// dotfile see the flag take effect. +// - HTTP triggers must return 503 with enableHow when the flag is off, +// not 500. + +describe("isSlotsEnabled — reads merged env (#678)", () => { + let home: string; + let ORIG_HOME: string | undefined; + let ORIG_FLAG: string | undefined; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "am-slots-flag-")); + mkdirSync(join(home, ".agentmemory"), { recursive: true }); + ORIG_HOME = process.env["HOME"]; + ORIG_FLAG = process.env["AGENTMEMORY_SLOTS"]; + process.env["HOME"] = home; + delete process.env["AGENTMEMORY_SLOTS"]; + vi.resetModules(); + }); + + afterEach(() => { + if (ORIG_HOME !== undefined) process.env["HOME"] = ORIG_HOME; + if (ORIG_FLAG !== undefined) process.env["AGENTMEMORY_SLOTS"] = ORIG_FLAG; + else delete process.env["AGENTMEMORY_SLOTS"]; + rmSync(home, { recursive: true, force: true }); + }); + + it("returns false when neither process.env nor .env sets the flag", async () => { + const { isSlotsEnabled } = await import("../src/functions/slots.js"); + expect(isSlotsEnabled()).toBe(false); + }); + + it("returns true when AGENTMEMORY_SLOTS=true lives only in ~/.agentmemory/.env", async () => { + writeFileSync( + join(home, ".agentmemory", ".env"), + "AGENTMEMORY_SLOTS=true\n", + ); + const { isSlotsEnabled } = await import("../src/functions/slots.js"); + expect(isSlotsEnabled()).toBe(true); + }); + + it("returns true when process.env wins over .env (existing behaviour preserved)", async () => { + writeFileSync( + join(home, ".agentmemory", ".env"), + "AGENTMEMORY_SLOTS=false\n", + ); + process.env["AGENTMEMORY_SLOTS"] = "true"; + const { isSlotsEnabled } = await import("../src/functions/slots.js"); + expect(isSlotsEnabled()).toBe(true); + }); +}); + +describe("isReflectEnabled — reads merged env (#678)", () => { + let home: string; + let ORIG_HOME: string | undefined; + let ORIG_FLAG: string | undefined; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "am-reflect-flag-")); + mkdirSync(join(home, ".agentmemory"), { recursive: true }); + ORIG_HOME = process.env["HOME"]; + ORIG_FLAG = process.env["AGENTMEMORY_REFLECT"]; + process.env["HOME"] = home; + delete process.env["AGENTMEMORY_REFLECT"]; + vi.resetModules(); + }); + + afterEach(() => { + if (ORIG_HOME !== undefined) process.env["HOME"] = ORIG_HOME; + if (ORIG_FLAG !== undefined) process.env["AGENTMEMORY_REFLECT"] = ORIG_FLAG; + else delete process.env["AGENTMEMORY_REFLECT"]; + rmSync(home, { recursive: true, force: true }); + }); + + it("returns true when AGENTMEMORY_REFLECT=true is only in ~/.agentmemory/.env", async () => { + writeFileSync( + join(home, ".agentmemory", ".env"), + "AGENTMEMORY_REFLECT=true\n", + ); + const { isReflectEnabled } = await import("../src/functions/slots.js"); + expect(isReflectEnabled()).toBe(true); + }); +}); diff --git a/test/slots.test.ts b/test/slots.test.ts new file mode 100644 index 0000000..70da2ae --- /dev/null +++ b/test/slots.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { registerSlotsFunctions, DEFAULT_SLOTS, listPinnedSlots, renderPinnedContext } from "../src/functions/slots.js"; +import { KV } from "../src/state/schema.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + if (!store.has(scope)) return []; + return Array.from(store.get(scope)!.values()) as T[]; + }, + }; +} + +function wire() { + const kv = mockKV(); + const handlers: Record) => Promise>> = {}; + const sdk = { + registerFunction: vi.fn((id: string, cb) => { + handlers[id] = cb; + }), + } as unknown as import("iii-sdk").ISdk; + registerSlotsFunctions(sdk, kv as never); + return { kv, handlers }; +} + +async function waitForSeed(kv: ReturnType) { + for (let i = 0; i < 20; i++) { + const p = await kv.list(KV.slots); + const g = await kv.list(KV.globalSlots); + if (p.length + g.length >= DEFAULT_SLOTS.length) return; + await new Promise((r) => setTimeout(r, 5)); + } +} + +describe("slots — primitive", () => { + let kv: ReturnType; + let handlers: Record) => Promise>>; + + beforeEach(async () => { + ({ kv, handlers } = wire()); + await waitForSeed(kv); + }); + + it("seeds default slots into the right scopes on first run", async () => { + const global = (await kv.list(KV.globalSlots)) as Array<{ label: string }>; + const project = (await kv.list(KV.slots)) as Array<{ label: string }>; + expect(global.map((s) => s.label).sort()).toEqual( + ["persona", "tool_guidelines", "user_preferences"].sort(), + ); + expect(project.map((s) => s.label).sort().length).toBeGreaterThanOrEqual(5); + }); + + it("rejects labels with bad shape", async () => { + const res = (await handlers["mem::slot-create"]({ label: "Bad Label!" })) as { success: boolean; error: string }; + expect(res.success).toBe(false); + expect(res.error).toMatch(/label required/); + }); + + it("create then get round-trips a new slot", async () => { + const created = (await handlers["mem::slot-create"]({ + label: "notes_todo", + content: "hello", + description: "scratchpad", + })) as { success: boolean; slot: { label: string; content: string } }; + expect(created.success).toBe(true); + expect(created.slot.content).toBe("hello"); + + const fetched = (await handlers["mem::slot-get"]({ label: "notes_todo" })) as { + success: boolean; + slot: { content: string }; + }; + expect(fetched.success).toBe(true); + expect(fetched.slot.content).toBe("hello"); + }); + + it("rejects duplicate create", async () => { + await handlers["mem::slot-create"]({ label: "scratch", content: "a" }); + const dup = (await handlers["mem::slot-create"]({ label: "scratch", content: "b" })) as { + success: boolean; + error: string; + }; + expect(dup.success).toBe(false); + expect(dup.error).toMatch(/already exists/); + }); + + it("append refuses writes that would blow the sizeLimit", async () => { + await handlers["mem::slot-create"]({ label: "tight", content: "", sizeLimit: 10 }); + const ok = (await handlers["mem::slot-append"]({ label: "tight", text: "short" })) as { success: boolean }; + expect(ok.success).toBe(true); + const tooBig = (await handlers["mem::slot-append"]({ label: "tight", text: "way too long for this slot" })) as { + success: boolean; + error: string; + }; + expect(tooBig.success).toBe(false); + expect(tooBig.error).toMatch(/exceed sizeLimit/); + }); + + it("replace refuses content above sizeLimit", async () => { + await handlers["mem::slot-create"]({ label: "tiny", content: "", sizeLimit: 5 }); + const res = (await handlers["mem::slot-replace"]({ label: "tiny", content: "exceeds" })) as { + success: boolean; + error: string; + }; + expect(res.success).toBe(false); + expect(res.error).toMatch(/exceeds/); + }); + + it("delete removes the slot", async () => { + await handlers["mem::slot-create"]({ label: "throwaway", content: "bye" }); + const del = (await handlers["mem::slot-delete"]({ label: "throwaway" })) as { success: boolean }; + expect(del.success).toBe(true); + const get = (await handlers["mem::slot-get"]({ label: "throwaway" })) as { success: boolean }; + expect(get.success).toBe(false); + }); + + it("project slot shadows global slot of the same label", async () => { + // Default seed already created a global `persona`. Populate it through + // the public handler, then create a project-scoped override through the + // same handler so scope validation + shadowing logic is exercised end + // to end (no direct kv.set). + await handlers["mem::slot-replace"]({ label: "persona", content: "global-persona" }); + const createRes = (await handlers["mem::slot-create"]({ + label: "persona", + content: "project-override", + scope: "project", + })) as { success: boolean }; + expect(createRes.success).toBe(true); + + const res = (await handlers["mem::slot-get"]({ label: "persona" })) as { + slot: { content: string }; + scope: string; + }; + expect(res.slot.content).toBe("project-override"); + expect(res.scope).toBe("project"); + }); + + it("rejects invalid sizeLimit instead of silently defaulting", async () => { + const tooBig = (await handlers["mem::slot-create"]({ + label: "oversize", + sizeLimit: 99999, + })) as { success: boolean; error: string }; + expect(tooBig.success).toBe(false); + expect(tooBig.error).toMatch(/sizeLimit must be/); + + const negative = (await handlers["mem::slot-create"]({ + label: "negative", + sizeLimit: -1, + })) as { success: boolean; error: string }; + expect(negative.success).toBe(false); + + const nonInteger = (await handlers["mem::slot-create"]({ + label: "fractional", + sizeLimit: 1.5, + })) as { success: boolean; error: string }; + expect(nonInteger.success).toBe(false); + }); + + it("rejects unknown scope values", async () => { + const res = (await handlers["mem::slot-create"]({ + label: "bad_scope", + scope: "wrong" as unknown as "project", + })) as { success: boolean; error: string }; + expect(res.success).toBe(false); + expect(res.error).toMatch(/scope must be/); + }); + + it("listPinnedSlots returns only pinned slots with content", async () => { + await handlers["mem::slot-append"]({ label: "persona", text: "helpful senior engineer" }); + const pinned = await listPinnedSlots(kv as never); + expect(pinned.some((s) => s.label === "persona")).toBe(true); + expect(pinned.every((s) => s.pinned && s.content.trim().length > 0)).toBe(true); + }); + + it("renderPinnedContext serialises slots into markdown", async () => { + await handlers["mem::slot-append"]({ label: "persona", text: "senior eng" }); + const pinned = await listPinnedSlots(kv as never); + const rendered = renderPinnedContext(pinned); + expect(rendered).toContain("## persona"); + expect(rendered).toContain("senior eng"); + }); +}); + +describe("slots — reflect", () => { + let kv: ReturnType; + let handlers: Record) => Promise>>; + + beforeEach(async () => { + ({ kv, handlers } = wire()); + await waitForSeed(kv); + }); + + it("no-ops when the session has no observations", async () => { + const res = (await handlers["mem::slot-reflect"]({ sessionId: "empty-session" })) as { + success: boolean; + applied: number; + }; + expect(res.success).toBe(true); + expect(res.applied).toBe(0); + }); + + it("moves TODO-flavoured observations into pending_items and counts patterns", async () => { + const sessionId = "sess_reflect"; + const obsKey = KV.observations(sessionId); + const base = { + id: "obs1", + sessionId, + timestamp: new Date().toISOString(), + type: "error" as const, + title: "TODO: wire up retries", + subtitle: "", + facts: [], + narrative: "agent left a TODO for retries", + concepts: [], + files: ["src/retry.ts"], + importance: 5, + }; + await kv.set(obsKey, "obs1", base); + await kv.set(obsKey, "obs2", { + ...base, + id: "obs2", + title: "compile error", + narrative: "tsc failed", + files: ["src/other.ts"], + type: "error", + }); + const res = (await handlers["mem::slot-reflect"]({ sessionId })) as { + success: boolean; + applied: number; + observationsReviewed: number; + }; + expect(res.success).toBe(true); + expect(res.observationsReviewed).toBe(2); + expect(res.applied).toBeGreaterThan(0); + + const pending = (await handlers["mem::slot-get"]({ label: "pending_items" })) as { + slot: { content: string }; + }; + expect(pending.slot.content).toContain("TODO: wire up retries"); + + const patterns = (await handlers["mem::slot-get"]({ label: "session_patterns" })) as { + slot: { content: string }; + }; + expect(patterns.slot.content).toMatch(/errors: 2/); + }); +}); diff --git a/test/smart-search.test.ts b/test/smart-search.test.ts new file mode 100644 index 0000000..9d0c94e --- /dev/null +++ b/test/smart-search.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerSmartSearchFunction } from "../src/functions/smart-search.js"; +import type { + CompressedObservation, + HybridSearchResult, + CompactSearchResult, + Session, +} from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeObs( + overrides: Partial = {}, +): CompressedObservation { + return { + id: "obs_1", + sessionId: "ses_1", + timestamp: "2026-02-01T10:00:00Z", + type: "file_edit", + title: "Edit auth handler", + facts: [], + narrative: "Modified auth", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 7, + ...overrides, + }; +} + +describe("Smart Search Function", () => { + let sdk: ReturnType; + let kv: ReturnType; + let searchResults: HybridSearchResult[]; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + + const obs1 = makeObs({ id: "obs_1", sessionId: "ses_1", title: "Auth handler" }); + const obs2 = makeObs({ id: "obs_2", sessionId: "ses_1", title: "Database setup" }); + + searchResults = [ + { + observation: obs1, + bm25Score: 0.8, + vectorScore: 0, + combinedScore: 0.8, + sessionId: "ses_1", + }, + { + observation: obs2, + bm25Score: 0.3, + vectorScore: 0, + combinedScore: 0.3, + sessionId: "ses_1", + }, + ]; + + const session: Session = { + id: "ses_1", + project: "my-project", + cwd: "/tmp", + startedAt: "2026-02-01T00:00:00Z", + status: "completed", + observationCount: 2, + }; + await kv.set("mem:sessions", "ses_1", session); + await kv.set("mem:obs:ses_1", "obs_1", obs1); + await kv.set("mem:obs:ses_1", "obs_2", obs2); + + const searchFn = async (_query: string, _limit: number) => searchResults; + registerSmartSearchFunction(sdk as never, kv as never, searchFn); + }); + + it("compact mode returns CompactSearchResult array", async () => { + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + })) as { mode: string; results: CompactSearchResult[] }; + + expect(result.mode).toBe("compact"); + expect(result.results.length).toBe(2); + expect(result.results[0]).toHaveProperty("obsId"); + expect(result.results[0]).toHaveProperty("title"); + expect(result.results[0]).toHaveProperty("type"); + expect(result.results[0]).toHaveProperty("score"); + expect(result.results[0]).toHaveProperty("timestamp"); + expect(result.results[0]).not.toHaveProperty("narrative"); + }); + + it("expand mode returns full observations for given IDs", async () => { + const result = (await sdk.trigger("mem::smart-search", { + expandIds: ["obs_1"], + })) as { mode: string; results: Array<{ obsId: string; observation: CompressedObservation }> }; + + expect(result.mode).toBe("expanded"); + expect(result.results.length).toBe(1); + expect(result.results[0].observation.title).toBe("Auth handler"); + }); + + it("returns error when query is missing and no expandIds", async () => { + const result = (await sdk.trigger("mem::smart-search", {})) as { + mode: string; + error: string; + }; + + expect(result.mode).toBe("compact"); + expect(result.error).toBe("query is required"); + expect((result as { results: unknown[] }).results).toEqual([]); + }); + + it("respects limit parameter in compact mode", async () => { + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + limit: 1, + })) as { mode: string; results: CompactSearchResult[] }; + + expect(result.results.length).toBeLessThanOrEqual(2); + }); + + it("expand returns empty for nonexistent observation IDs", async () => { + const result = (await sdk.trigger("mem::smart-search", { + expandIds: ["obs_nonexistent_ses_xxx"], + })) as { mode: string; results: unknown[] }; + + expect(result.mode).toBe("expanded"); + expect(result.results.length).toBe(0); + }); + + it("compact mode records access for every returned observation id (#119)", async () => { + await sdk.trigger("mem::smart-search", { query: "auth" }); + // recordAccessBatch is fire-and-forget — let the microtask queue drain. + await new Promise((r) => setImmediate(r)); + + const log1 = (await kv.get("mem:access", "obs_1")) as { + count: number; + } | null; + const log2 = (await kv.get("mem:access", "obs_2")) as { + count: number; + } | null; + + expect(log1?.count).toBe(1); + expect(log2?.count).toBe(1); + }); + + it("expand mode records access for expanded observation ids (#119)", async () => { + await sdk.trigger("mem::smart-search", { expandIds: ["obs_1"] }); + await new Promise((r) => setImmediate(r)); + + const log = (await kv.get("mem:access", "obs_1")) as { + count: number; + } | null; + expect(log?.count).toBe(1); + }); + + describe("lesson inclusion (#lesson-visibility)", () => { + it("compact mode returns lessons array alongside observation results", async () => { + sdk.registerFunction("mem::lesson-recall", async (payload: any) => ({ + success: true, + lessons: [ + { id: "lsn_a", content: "always rebase before push", confidence: 0.9, createdAt: "2026-04-01T00:00:00Z", project: "p", tags: ["git"], score: 0.81 }, + { id: "lsn_b", content: "never force-push to main", confidence: 0.95, createdAt: "2026-04-02T00:00:00Z", project: "p", tags: ["git"], score: 0.76 }, + ], + })); + + const result = (await sdk.trigger("mem::smart-search", { + query: "rebase", + })) as { mode: string; results: CompactSearchResult[]; lessons?: any[] }; + + expect(result.mode).toBe("compact"); + expect(result.results.length).toBe(2); // observations unchanged + expect(result.lessons).toBeDefined(); + expect(result.lessons!.length).toBe(2); + expect(result.lessons![0]).toMatchObject({ + lessonId: "lsn_a", + confidence: 0.9, + score: 0.81, + }); + expect(result.lessons![0].tags).toEqual(["git"]); + }); + + it("compact mode truncates long lesson content for preview", async () => { + const long = "x".repeat(500); + sdk.registerFunction("mem::lesson-recall", async () => ({ + success: true, + lessons: [{ id: "lsn_long", content: long, confidence: 0.5, createdAt: "", tags: [], score: 0.4 }], + })); + + const result = (await sdk.trigger("mem::smart-search", { + query: "x", + })) as { lessons: any[] }; + + expect(result.lessons[0].content.length).toBeLessThan(long.length); + expect(result.lessons[0].content).toMatch(/…$/); + }); + + it("includeLessons:false omits the lessons array entirely", async () => { + // No lesson-recall handler registered — would throw if invoked. + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + includeLessons: false, + })) as { mode: string; results: CompactSearchResult[]; lessons?: unknown }; + + expect(result.results.length).toBe(2); + expect(result.lessons).toBeUndefined(); + }); + + it("forwards project filter to mem::lesson-recall", async () => { + let receivedPayload: any = null; + sdk.registerFunction("mem::lesson-recall", async (payload: any) => { + receivedPayload = payload; + return { success: true, lessons: [] }; + }); + + await sdk.trigger("mem::smart-search", { + query: "rebase", + project: "gitops-assistant", + }); + + expect(receivedPayload).toMatchObject({ + query: "rebase", + project: "gitops-assistant", + }); + }); + + it("tolerates mem::lesson-recall failure: returns empty lessons, observations unchanged", async () => { + sdk.registerFunction("mem::lesson-recall", async () => { + throw new Error("lessons store unavailable"); + }); + + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + })) as { results: CompactSearchResult[]; lessons: any[] }; + + expect(result.results.length).toBe(2); + expect(result.lessons).toEqual([]); + }); + + it("tolerates non-success lesson-recall response shape", async () => { + sdk.registerFunction("mem::lesson-recall", async () => ({ + success: false, + error: "query is required", + })); + + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + })) as { results: CompactSearchResult[]; lessons: any[] }; + + expect(result.results.length).toBe(2); + expect(result.lessons).toEqual([]); + }); + }); +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts new file mode 100644 index 0000000..198774d --- /dev/null +++ b/test/snapshot.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("node:child_process", () => ({ + execFile: vi.fn( + (_cmd: string, _args: string[], _opts: unknown, cb: Function) => { + cb(null, { stdout: "abc1234\n", stderr: "" }); + }, + ), +})); + +vi.mock("node:util", async () => { + const actual = (await vi.importActual("node:util")) as Record< + string, + unknown + >; + return { + ...actual, + promisify: () => async () => ({ stdout: "abc1234\n", stderr: "" }), + }; +}); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn().mockReturnValue(true), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + readFileSync: vi + .fn() + .mockReturnValue('{"version":"0.4.0","sessions":[],"memories":[]}'), +})); + +import { registerSnapshotFunction } from "../src/functions/snapshot.js"; +import type { Session, Memory, SnapshotMeta } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +describe("Snapshot Functions", () => { + let sdk: ReturnType; + let kv: ReturnType; + const snapshotDir = "/tmp/agentmemory-snapshots"; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + vi.clearAllMocks(); + registerSnapshotFunction(sdk as never, kv as never, snapshotDir); + + const session: Session = { + id: "ses_1", + project: "test", + cwd: "/tmp", + startedAt: "2026-02-01T00:00:00Z", + status: "completed", + observationCount: 1, + }; + await kv.set("mem:sessions", "ses_1", session); + + const mem: Memory = { + id: "mem_1", + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type: "pattern", + title: "Test pattern", + content: "Always test", + concepts: [], + files: [], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, + }; + await kv.set("mem:memories", "mem_1", mem); + }); + + it("snapshot-create serializes state and returns meta", async () => { + const result = (await sdk.trigger("mem::snapshot-create", { + message: "Test snapshot", + })) as { success: boolean; snapshot: SnapshotMeta }; + + expect(result.success).toBe(true); + expect(result.snapshot).toBeDefined(); + expect(result.snapshot.commitHash).toBe("abc1234"); + expect(result.snapshot.message).toBe("Test snapshot"); + expect(result.snapshot.stats.sessions).toBe(1); + expect(result.snapshot.stats.memories).toBe(1); + }); + + it("snapshot-list returns snapshots from git log", async () => { + const result = (await sdk.trigger("mem::snapshot-list", {})) as { + snapshots: Array<{ + commitHash: string; + createdAt: string; + message: string; + }>; + }; + + expect(result.snapshots).toBeDefined(); + expect(Array.isArray(result.snapshots)).toBe(true); + }); + + it("snapshot-restore requires commitHash", async () => { + const result = (await sdk.trigger("mem::snapshot-restore", {})) as { + success: boolean; + error: string; + }; + + expect(result.success).toBe(false); + expect(result.error).toContain("commitHash"); + }); + + it("snapshot-restore loads state from commit", async () => { + const result = (await sdk.trigger("mem::snapshot-restore", { + commitHash: "abc1234", + })) as { success: boolean; commitHash: string }; + + expect(result.success).toBe(true); + expect(result.commitHash).toBe("abc1234"); + }); + + it("snapshot-create records an audit entry", async () => { + await sdk.trigger("mem::snapshot-create", { message: "Audit test" }); + + const audits = await kv.list("mem:audit"); + expect(audits.length).toBe(1); + }); +}); diff --git a/test/stop-hook-recursion-guard.test.ts b/test/stop-hook-recursion-guard.test.ts new file mode 100644 index 0000000..55d0f91 --- /dev/null +++ b/test/stop-hook-recursion-guard.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { isSdkChildContext } from "../src/hooks/sdk-guard.js"; +import { NoopProvider } from "../src/providers/noop.js"; + +describe("isSdkChildContext — Stop hook recursion guard", () => { + const originalEnv = process.env.AGENTMEMORY_SDK_CHILD; + + beforeEach(() => { + delete process.env.AGENTMEMORY_SDK_CHILD; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AGENTMEMORY_SDK_CHILD; + } else { + process.env.AGENTMEMORY_SDK_CHILD = originalEnv; + } + }); + + it("returns true when AGENTMEMORY_SDK_CHILD=1 is in env", () => { + process.env.AGENTMEMORY_SDK_CHILD = "1"; + expect(isSdkChildContext({})).toBe(true); + }); + + it("returns true when payload.entrypoint === 'sdk-ts'", () => { + expect(isSdkChildContext({ entrypoint: "sdk-ts" })).toBe(true); + }); + + it("returns false for a normal CC payload", () => { + expect(isSdkChildContext({ entrypoint: "cli", session_id: "s1" })).toBe(false); + }); + + it("returns false when payload is null / undefined / non-object", () => { + expect(isSdkChildContext(null)).toBe(false); + expect(isSdkChildContext(undefined)).toBe(false); + expect(isSdkChildContext("not-an-object")).toBe(false); + expect(isSdkChildContext(42)).toBe(false); + }); + + it("env marker wins over payload shape", () => { + process.env.AGENTMEMORY_SDK_CHILD = "1"; + expect(isSdkChildContext({ entrypoint: "cli" })).toBe(true); + }); +}); + +describe("NoopProvider — no-op fallback when no LLM key present", () => { + it("reports name 'noop' so callers can detect it and short-circuit", () => { + const p = new NoopProvider(); + expect(p.name).toBe("noop"); + }); + + it("returns empty string for compress and summarize", async () => { + const p = new NoopProvider(); + await expect(p.compress()).resolves.toBe(""); + await expect(p.summarize()).resolves.toBe(""); + }); +}); diff --git a/test/stop-worker-pidfile.test.ts b/test/stop-worker-pidfile.test.ts new file mode 100644 index 0000000..c70aaf1 --- /dev/null +++ b/test/stop-worker-pidfile.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// #640 + #474: stop must also kill the worker process, not just the +// iii engine. We expose the worker pidfile from src/index.ts and read it +// from src/cli.ts. Static check that both files agree on the path +// (~/.agentmemory/worker.pid) and that stop reads it. +describe("stop reaps the worker process (#640, #474)", () => { + it("src/index.ts writes worker.pid alongside iii.pid", () => { + const source = readFileSync("src/index.ts", "utf-8"); + expect(source).toMatch(/workerPidfilePath\(\)/); + expect(source).toMatch(/"worker\.pid"/); + expect(source).toMatch(/writeWorkerPidfile\(\)/); + expect(source).toMatch(/clearWorkerPidfile\(\)/); + }); + + it("src/cli.ts reads worker.pid in runStop and signals it on stop", () => { + const source = readFileSync("src/cli.ts", "utf-8"); + expect(source).toMatch(/workerPidfilePath\(\)/); + expect(source).toMatch(/"worker\.pid"/); + expect(source).toMatch(/readWorkerPidfile\(\)/); + expect(source).toMatch(/clearWorkerPidfile\(\)/); + // Verify stop wiring: workerCandidates set is built from the pidfile + // and signaled alongside the engine pids. + expect(source).toMatch(/workerCandidates/); + expect(source).toMatch(/Stopping agentmemory worker/); + }); + + it("both files agree on the pidfile path: ~/.agentmemory/worker.pid", () => { + const indexSrc = readFileSync("src/index.ts", "utf-8"); + const cliSrc = readFileSync("src/cli.ts", "utf-8"); + expect(indexSrc).toMatch(/\.agentmemory["'].*worker\.pid|"worker\.pid"/); + expect(cliSrc).toMatch(/\.agentmemory["'].*worker\.pid|"worker\.pid"/); + }); +}); diff --git a/test/summarize.test.ts b/test/summarize.test.ts new file mode 100644 index 0000000..4723da8 --- /dev/null +++ b/test/summarize.test.ts @@ -0,0 +1,482 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/schema.js", () => ({ + KV: { + sessions: "sessions", + summaries: "summaries", + observations: (sessionId: string) => `obs:${sessionId}`, + audit: "audit", + }, +})); + +vi.mock("../src/eval/schemas.js", () => ({ + SummaryOutputSchema: {}, +})); + +vi.mock("../src/eval/validator.js", () => ({ + validateOutput: () => ({ valid: true, result: { errors: [] } }), +})); + +vi.mock("../src/eval/quality.js", () => ({ + scoreSummary: () => 100, +})); + +vi.mock("../src/functions/audit.js", () => ({ + safeAudit: vi.fn(), +})); + +import { registerSummarizeFunction } from "../src/functions/summarize.js"; +import type { + CompressedObservation, + Session, + MemoryProvider, +} from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + store, + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + return { + functions, + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async () => ({}), + }; +} + +function makeObs(i: number, sessionId: string): CompressedObservation { + return { + id: `obs_${i}`, + sessionId, + timestamp: new Date().toISOString(), + type: "conversation", + title: `obs ${i}`, + facts: [`fact ${i}`], + narrative: `narrative for obs ${i}`, + concepts: [], + files: [`src/file_${i}.ts`], + importance: 5, + }; +} + +function makeProvider(responses: string[]): MemoryProvider & { + calls: Array<{ system: string; user: string }>; +} { + const calls: Array<{ system: string; user: string }> = []; + let i = 0; + return { + name: "test", + calls, + compress: async () => "", + summarize: async (system: string, user: string) => { + calls.push({ system, user }); + const r = responses[i] ?? responses[responses.length - 1]; + i += 1; + return r; + }, + }; +} + +function summaryXml(opts: { + title: string; + narrative?: string; + decisions?: string[]; + files?: string[]; + concepts?: string[]; +}): string { + const d = (opts.decisions ?? []).map((x) => `${x}`).join(""); + const f = (opts.files ?? []).map((x) => `${x}`).join(""); + const c = (opts.concepts ?? []).map((x) => `${x}`).join(""); + return ` +${opts.title} +${opts.narrative ?? "narrative"} +${d} +${f} +${c} +`; +} + +async function setupHandler(opts: { + sessionId: string; + obsCount: number; + provider: MemoryProvider; +}) { + const sdk = mockSdk(); + const kv = mockKV(); + const session: Session = { + id: opts.sessionId, + project: "test-project", + cwd: "/tmp", + startedAt: new Date().toISOString(), + status: "completed", + observationCount: opts.obsCount, + }; + await kv.set("sessions", opts.sessionId, session); + for (let i = 0; i < opts.obsCount; i++) { + const o = makeObs(i, opts.sessionId); + await kv.set(`obs:${opts.sessionId}`, o.id, o); + } + registerSummarizeFunction(sdk as any, kv as any, opts.provider); + const handler = sdk.functions.get("mem::summarize")!; + return { handler, kv }; +} + +describe("mem::summarize chunking", () => { + const ORIGINAL_ENV = { ...process.env }; + + beforeEach(() => { + delete process.env.SUMMARIZE_CHUNK_SIZE; + delete process.env.SUMMARIZE_CHUNK_CONCURRENCY; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("small session takes the single-call path (no chunking, no reduce)", async () => { + const provider = makeProvider([ + summaryXml({ + title: "Small session", + decisions: ["decision A"], + files: ["src/a.ts"], + concepts: ["concept-a"], + }), + ]); + const { handler, kv } = await setupHandler({ + sessionId: "ses_small", + obsCount: 10, + provider, + }); + + const result: any = await handler({ sessionId: "ses_small" }); + + expect(result.success).toBe(true); + expect(provider.calls).toHaveLength(1); + expect(provider.calls[0].user).toContain("Session observations (10 total)"); + const stored: any = await kv.get("summaries", "ses_small"); + expect(stored?.title).toBe("Small session"); + }); + + it("large session map-reduces: N chunk calls + 1 reduce call", async () => { + process.env.SUMMARIZE_CHUNK_SIZE = "100"; + process.env.SUMMARIZE_CHUNK_CONCURRENCY = "1"; // serial keeps call ordering deterministic + const provider = makeProvider([ + summaryXml({ title: "Chunk 1", decisions: ["dA"], files: ["src/a.ts"], concepts: ["ca"] }), + summaryXml({ title: "Chunk 2", decisions: ["dB"], files: ["src/b.ts"], concepts: ["cb"] }), + summaryXml({ title: "Chunk 3", decisions: ["dC"], files: ["src/c.ts"], concepts: ["cc"] }), + summaryXml({ + title: "Merged", + decisions: ["dA", "dB", "dC"], + files: ["src/a.ts", "src/b.ts", "src/c.ts"], + concepts: ["ca", "cb", "cc"], + }), + ]); + const { handler, kv } = await setupHandler({ + sessionId: "ses_large", + obsCount: 250, + provider, + }); + + const result: any = await handler({ sessionId: "ses_large" }); + + expect(result.success).toBe(true); + expect(provider.calls).toHaveLength(4); + // First three are chunk calls (use the summary system prompt). + expect(provider.calls[0].system).toContain("session summarizer"); + expect(provider.calls[2].system).toContain("session summarizer"); + // Last is the reduce call (uses the merge system prompt). + expect(provider.calls[3].system).toContain("merging multiple partial summaries"); + expect(provider.calls[3].user).toContain("Chunk 1 of 3"); + expect(provider.calls[3].user).toContain("Chunk 3 of 3"); + + const stored: any = await kv.get("summaries", "ses_large"); + expect(stored?.title).toBe("Merged"); + // observationCount on the persisted summary should reflect the full session, + // not just the final chunk. + expect(stored?.observationCount).toBe(250); + expect(stored?.keyDecisions).toEqual(["dA", "dB", "dC"]); + }); + + it("SUMMARIZE_CHUNK_SIZE env override is respected", async () => { + process.env.SUMMARIZE_CHUNK_SIZE = "50"; + process.env.SUMMARIZE_CHUNK_CONCURRENCY = "1"; + const provider = makeProvider([ + summaryXml({ title: "chunk" }), + summaryXml({ title: "chunk" }), + summaryXml({ title: "chunk" }), + summaryXml({ title: "chunk" }), + summaryXml({ title: "merged" }), + ]); + const { handler } = await setupHandler({ + sessionId: "ses_env", + obsCount: 175, + provider, + }); + + const result: any = await handler({ sessionId: "ses_env" }); + + expect(result.success).toBe(true); + // 175 obs ÷ 50 = 4 chunks (last chunk has 25) + 1 reduce = 5 calls. + expect(provider.calls).toHaveLength(5); + }); + + it("flaky chunk: parse fails once, retried, then succeeds — no skip", async () => { + process.env.SUMMARIZE_CHUNK_SIZE = "100"; + process.env.SUMMARIZE_CHUNK_CONCURRENCY = "1"; + const provider = makeProvider([ + summaryXml({ title: "ok1" }), + "", // chunk 2 attempt 1: parse-fail + summaryXml({ title: "ok2" }), // chunk 2 attempt 2 (retry): success + summaryXml({ title: "ok3" }), + summaryXml({ title: "merged" }), + ]); + const { handler, kv } = await setupHandler({ + sessionId: "ses_flaky", + obsCount: 250, + provider, + }); + + const result: any = await handler({ sessionId: "ses_flaky" }); + + expect(result.success).toBe(true); + // 3 chunks × 1 attempt + 1 retry on chunk 2 + 1 reduce = 5 calls. + expect(provider.calls).toHaveLength(5); + const stored: any = await kv.get("summaries", "ses_flaky"); + expect(stored?.title).toBe("merged"); + }); + + it("persistently-broken chunk is skipped, reduce still runs on remaining partials", async () => { + process.env.SUMMARIZE_CHUNK_SIZE = "100"; + process.env.SUMMARIZE_CHUNK_CONCURRENCY = "1"; + const provider = makeProvider([ + summaryXml({ title: "ok1" }), + "", "", // chunk 2: both attempts parse-fail + summaryXml({ title: "ok3" }), + summaryXml({ title: "merged-with-skip" }), + ]); + const { handler, kv } = await setupHandler({ + sessionId: "ses_skip", + obsCount: 250, + provider, + }); + + const result: any = await handler({ sessionId: "ses_skip" }); + + expect(result.success).toBe(true); + // 1 ok + (1 + 1 retry skip) + 1 ok + 1 reduce = 5 calls. + expect(provider.calls).toHaveLength(5); + // Reduce input should mention only 2 of 3 chunks (chunk 2 skipped) — + // but the chunk indices in the reduce labels should reflect chunk 1 and 3, + // preserving chronological boundaries. + const reduceCall = provider.calls[4]; + expect(reduceCall.user).toContain("Chunk 1 of 2"); + expect(reduceCall.user).toContain("Chunk 2 of 2"); + expect(reduceCall.user).toContain("obs 1-100"); // first surviving chunk + expect(reduceCall.user).toContain("obs 201-250"); // third surviving chunk (was idx 2, range 201-250) + const stored: any = await kv.get("summaries", "ses_skip"); + expect(stored?.title).toBe("merged-with-skip"); + }); + + it("too many skipped chunks bails out with a clear error", async () => { + process.env.SUMMARIZE_CHUNK_SIZE = "100"; + process.env.SUMMARIZE_CHUNK_CONCURRENCY = "1"; + // 3 chunks, 2 fully broken → >50% skipped → bail. + const provider = makeProvider([ + summaryXml({ title: "ok1" }), + "", "", + "", "", + ]); + const { handler } = await setupHandler({ + sessionId: "ses_too_broken", + obsCount: 250, + provider, + }); + + const result: any = await handler({ sessionId: "ses_too_broken" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/too_many_chunks_skipped: 2\/3/); + }); + + it("provider error on one chunk after retry is skipped, not propagated", async () => { + process.env.SUMMARIZE_CHUNK_SIZE = "100"; + process.env.SUMMARIZE_CHUNK_CONCURRENCY = "1"; + let i = 0; + const provider: MemoryProvider & { calls: any[] } = { + name: "test", + calls: [], + compress: async () => "", + summarize: async (system: string, user: string) => { + (provider as any).calls.push({ system, user }); + i += 1; + if (i === 1) return summaryXml({ title: "ok1" }); + // chunk 2: both attempts throw (e.g. provider 400) + if (i === 2 || i === 3) throw new Error("OpenAI API error (400): content rejected"); + if (i === 4) return summaryXml({ title: "ok3" }); + return summaryXml({ title: "merged-with-skip" }); + }, + }; + const { handler, kv } = await setupHandler({ + sessionId: "ses_net", + obsCount: 250, + provider, + }); + + const result: any = await handler({ sessionId: "ses_net" }); + + expect(result.success).toBe(true); + // 1 ok + 2 fail + 1 ok + 1 reduce = 5 calls. + expect((provider as any).calls.length).toBe(5); + const stored: any = await kv.get("summaries", "ses_net"); + expect(stored?.title).toBe("merged-with-skip"); + }); + + it("every chunk failing on provider error trips too_many_chunks_skipped", async () => { + process.env.SUMMARIZE_CHUNK_SIZE = "100"; + process.env.SUMMARIZE_CHUNK_CONCURRENCY = "1"; + // 3 chunks, all chunk calls throw → 3/3 skipped → bail. + const provider: MemoryProvider & { calls: any[] } = { + name: "test", + calls: [], + compress: async () => "", + summarize: async (system: string, user: string) => { + (provider as any).calls.push({ system, user }); + throw new Error("OpenAI API error (400): invalid request"); + }, + }; + const { handler } = await setupHandler({ + sessionId: "ses_all_400", + obsCount: 250, + provider, + }); + + const result: any = await handler({ sessionId: "ses_all_400" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/too_many_chunks_skipped: 3\/3/); + }); + + it("chunks run in parallel batches according to SUMMARIZE_CHUNK_CONCURRENCY", async () => { + process.env.SUMMARIZE_CHUNK_SIZE = "100"; + process.env.SUMMARIZE_CHUNK_CONCURRENCY = "2"; + let inflight = 0; + let maxInflight = 0; + const provider: MemoryProvider & { calls: any[] } = { + name: "test", + calls: [], + compress: async () => "", + summarize: async (system: string, user: string) => { + (provider as any).calls.push({ system, user }); + inflight += 1; + maxInflight = Math.max(maxInflight, inflight); + // Yield to event loop so siblings can also enter before we resolve. + await new Promise((r) => setTimeout(r, 5)); + inflight -= 1; + if (system.includes("merging")) return summaryXml({ title: "merged" }); + return summaryXml({ title: "ok" }); + }, + }; + const { handler } = await setupHandler({ + sessionId: "ses_par", + obsCount: 400, // 4 chunks at chunkSize=100 + provider, + }); + + const result: any = await handler({ sessionId: "ses_par" }); + + expect(result.success).toBe(true); + // 4 chunks at concurrency 2 → max 2 in flight at once during the chunk phase. + // Reduce is a single call so doesn't bump it. + expect(maxInflight).toBe(2); + }); + + // #783: markdown-wrapped XML used to silently fail parsing because + // the tag regex looked for in the raw payload. stripXmlWrappers + // now peels ```xml ... ``` fences and conversational pre/postamble + // before the regex runs. + it("parses a summary even when the LLM wraps XML in markdown fences", async () => { + const wrappedXml = "Here's the summary:\n```xml\n" + summaryXml({ + title: "wrapped", + narrative: "n", + decisions: ["d1"], + files: ["src/a.ts"], + concepts: ["c1"], + }) + "\n```\nLet me know if you need anything else."; + const provider = makeProvider([wrappedXml]); + const { handler, kv } = await setupHandler({ + sessionId: "ses_md", + obsCount: 1, + provider, + }); + + const result: any = await handler({ sessionId: "ses_md" }); + + expect(result.success).toBe(true); + expect(result.summary.title).toBe("wrapped"); + const stored = await kv.get("summaries", "ses_md"); + expect((stored as any).title).toBe("wrapped"); + }); + + it("retries the final summarize once on first-attempt parse failure", async () => { + // First call returns garbage (no <title>), second returns valid XML. + // The chunk-level retry is bypassed for a 1-obs session (no chunking), + // so this exercises the new final-summarize retry path. + const provider = makeProvider([ + "not xml, just a sentence with no tags", + summaryXml({ title: "second-attempt" }), + ]); + const { handler } = await setupHandler({ + sessionId: "ses_retry", + obsCount: 1, + provider, + }); + + const result: any = await handler({ sessionId: "ses_retry" }); + + expect(result.success).toBe(true); + expect(result.summary.title).toBe("second-attempt"); + expect((provider as any).calls.length).toBeGreaterThanOrEqual(2); + }); + + it("returns parse_failed only after both attempts fail", async () => { + const provider = makeProvider([ + "garbage one", + "garbage two", + ]); + const { handler } = await setupHandler({ + sessionId: "ses_fail", + obsCount: 1, + provider, + }); + + const result: any = await handler({ sessionId: "ses_fail" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("parse_failed"); + }); +}); diff --git a/test/team.test.ts b/test/team.test.ts new file mode 100644 index 0000000..b2aa1c6 --- /dev/null +++ b/test/team.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerTeamFunction } from "../src/functions/team.js"; +import type { Memory, TeamConfig, TeamSharedItem, TeamProfile } from "../src/types.js"; + +function mockKV() { + const store = new Map<string, Map<string, unknown>>(); + return { + get: async <T>(scope: string, key: string): Promise<T | null> => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async <T>(scope: string, key: string, data: T): Promise<T> => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise<void> => { + store.get(scope)?.delete(key); + }, + list: async <T>(scope: string): Promise<T[]> => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map<string, Function>(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +const teamConfig: TeamConfig = { + teamId: "test-team", + userId: "user-1", + mode: "shared" as const, +}; + +const testMemory: Memory = { + id: "mem_1", + createdAt: "2026-02-01T00:00:00Z", + updatedAt: "2026-02-01T00:00:00Z", + type: "pattern", + title: "Auth pattern", + content: "Always validate tokens", + concepts: ["auth", "security"], + files: ["src/auth.ts"], + sessionIds: ["ses_1"], + strength: 5, + version: 1, + isLatest: true, +}; + +describe("Team Functions", () => { + let sdk: ReturnType<typeof mockSdk>; + let kv: ReturnType<typeof mockKV>; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerTeamFunction(sdk as never, kv as never, teamConfig); + await kv.set("mem:memories", "mem_1", testMemory); + }); + + it("team-share stores item in team namespace", async () => { + const result = (await sdk.trigger("mem::team-share", { + itemId: "mem_1", + itemType: "memory", + })) as { success: boolean; sharedItem: TeamSharedItem }; + + expect(result.success).toBe(true); + expect(result.sharedItem.sharedBy).toBe("user-1"); + expect(result.sharedItem.type).toBe("memory"); + expect(result.sharedItem.visibility).toBe("shared"); + + const items = await kv.list<TeamSharedItem>("mem:team:test-team:shared"); + expect(items.length).toBe(1); + }); + + it("team-share fails for missing itemId", async () => { + const result = (await sdk.trigger("mem::team-share", { + itemType: "memory", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("required"); + }); + + it("team-feed returns shared items sorted by date", async () => { + await sdk.trigger("mem::team-share", { + itemId: "mem_1", + itemType: "memory", + }); + + const mem2: Memory = { ...testMemory, id: "mem_2", title: "Second pattern" }; + await kv.set("mem:memories", "mem_2", mem2); + + await new Promise((r) => setTimeout(r, 10)); + await sdk.trigger("mem::team-share", { + itemId: "mem_2", + itemType: "memory", + }); + + const result = (await sdk.trigger("mem::team-feed", {})) as { + items: TeamSharedItem[]; + total: number; + }; + + expect(result.items.length).toBe(2); + expect(result.total).toBe(2); + expect( + new Date(result.items[0].sharedAt).getTime(), + ).toBeGreaterThanOrEqual(new Date(result.items[1].sharedAt).getTime()); + }); + + it("team-profile aggregates concepts and files", async () => { + await sdk.trigger("mem::team-share", { + itemId: "mem_1", + itemType: "pattern", + }); + + const result = (await sdk.trigger("mem::team-profile", {})) as TeamProfile; + + expect(result.teamId).toBe("test-team"); + expect(result.members).toContain("user-1"); + expect(result.totalSharedItems).toBe(1); + expect(result.topConcepts.length).toBeGreaterThan(0); + expect(result.topConcepts.some((c) => c.concept === "auth")).toBe(true); + expect(result.topFiles.some((f) => f.file === "src/auth.ts")).toBe(true); + }); + + it("team-share fails when item not found in KV", async () => { + const result = (await sdk.trigger("mem::team-share", { + itemId: "nonexistent", + itemType: "memory", + })) as { success: boolean; error: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain("not found"); + }); +}); diff --git a/test/temporal-graph.test.ts b/test/temporal-graph.test.ts new file mode 100644 index 0000000..55ef301 --- /dev/null +++ b/test/temporal-graph.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect, vi } from "vitest"; +import type { GraphNode, GraphEdge, MemoryProvider } from "../src/types.js"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function mockKV( + nodes: GraphNode[] = [], + edges: GraphEdge[] = [], +) { + const store = new Map<string, Map<string, unknown>>(); + const nodesMap = new Map<string, unknown>(); + for (const n of nodes) nodesMap.set(n.id, n); + store.set("mem:graph:nodes", nodesMap); + + const edgesMap = new Map<string, unknown>(); + for (const e of edges) edgesMap.set(e.id, e); + store.set("mem:graph:edges", edgesMap); + + store.set("mem:graph:edge-history", new Map()); + + return { + get: async <T>(scope: string, key: string): Promise<T | null> => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async <T>(scope: string, key: string, data: T): Promise<T> => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise<void> => { + store.get(scope)?.delete(key); + }, + list: async <T>(scope: string): Promise<T[]> => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map<string, Function>(); + return { + registerFunction: (idOrOpts: string | { id: string }, fn: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, fn); + }, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (fn) return fn(payload); + return null; + }, + }; +} + +describe("TemporalGraph", () => { + it("imports without errors", async () => { + const mod = await import("../src/functions/temporal-graph.js"); + expect(mod.registerTemporalGraphFunctions).toBeDefined(); + }); + + it("registers all three functions", async () => { + const { registerTemporalGraphFunctions } = await import( + "../src/functions/temporal-graph.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + const provider: MemoryProvider = { + name: "test", + compress: vi.fn().mockResolvedValue(""), + summarize: vi.fn().mockResolvedValue(""), + }; + + registerTemporalGraphFunctions(sdk as never, kv as never, provider); + + const fns = Array.from((sdk as any).trigger ? [] : []); + expect(sdk.trigger).toBeDefined(); + }); + + it("extracts temporal graph with context metadata", async () => { + const { registerTemporalGraphFunctions } = await import( + "../src/functions/temporal-graph.js" + ); + + const response = `<temporal_graph> + <entities> + <entity type="person" name="Alice"> + <property key="role">engineer</property> + </entity> + <entity type="organization" name="Acme Corp"> + <property key="industry">tech</property> + </entity> + </entities> + <relationships> + <relationship type="works_at" source="Alice" target="Acme Corp" weight="0.9" valid_from="2024-01-01" valid_to="current"> + <reasoning>Alice joined Acme Corp as an engineer</reasoning> + <sentiment>positive</sentiment> + </relationship> + </relationships> +</temporal_graph>`; + + const provider: MemoryProvider = { + name: "test", + compress: vi.fn().mockResolvedValue(response), + summarize: vi.fn().mockResolvedValue(response), + }; + + const sdk = mockSdk(); + const kv = mockKV(); + registerTemporalGraphFunctions(sdk as never, kv as never, provider); + + const result = (await sdk.trigger("mem::temporal-graph-extract", { + observations: [ + { + id: "obs_1", + title: "Alice at Acme", + narrative: "Alice works at Acme Corp as an engineer", + concepts: ["career"], + files: [], + type: "conversation", + timestamp: "2024-01-01T00:00:00Z", + }, + ], + })) as { success: boolean; nodesAdded: number; edgesAdded: number }; + + expect(result.success).toBe(true); + expect(result.nodesAdded).toBe(2); + expect(result.edgesAdded).toBe(1); + + const storedEdges = await kv.list<GraphEdge>("mem:graph:edges"); + expect(storedEdges.length).toBe(1); + expect(storedEdges[0].tcommit).toBeDefined(); + expect(storedEdges[0].tvalid).toBe("2024-01-01"); + expect(storedEdges[0].context?.reasoning).toBe( + "Alice joined Acme Corp as an engineer", + ); + expect(storedEdges[0].context?.sentiment).toBe("positive"); + expect(storedEdges[0].isLatest).toBe(true); + expect(storedEdges[0].version).toBe(1); + }); + + it("appends new edge version instead of overwriting", async () => { + const { registerTemporalGraphFunctions } = await import( + "../src/functions/temporal-graph.js" + ); + + const existingNode: GraphNode = { + id: "gn_existing_alice", + type: "person", + name: "Alice", + properties: { role: "engineer" }, + sourceObservationIds: ["obs_0"], + createdAt: "2024-01-01T00:00:00Z", + }; + const existingNode2: GraphNode = { + id: "gn_existing_acme", + type: "organization", + name: "Acme Corp", + properties: {}, + sourceObservationIds: ["obs_0"], + createdAt: "2024-01-01T00:00:00Z", + }; + const existingEdge: GraphEdge = { + id: "ge_old", + type: "works_at" as any, + sourceNodeId: "gn_existing_alice", + targetNodeId: "gn_existing_acme", + weight: 0.9, + sourceObservationIds: ["obs_0"], + createdAt: "2024-01-01T00:00:00Z", + tcommit: "2024-01-01T00:00:00Z", + tvalid: "2024-01-01", + version: 1, + isLatest: true, + }; + + const response = `<temporal_graph> + <entities> + <entity type="person" name="Alice"> + <property key="role">senior engineer</property> + </entity> + <entity type="organization" name="Acme Corp"> + </entity> + </entities> + <relationships> + <relationship type="works_at" source="Alice" target="Acme Corp" weight="0.95" valid_from="2025-01-01" valid_to="current"> + <reasoning>Alice was promoted to senior engineer</reasoning> + <sentiment>positive</sentiment> + </relationship> + </relationships> +</temporal_graph>`; + + const provider: MemoryProvider = { + name: "test", + compress: vi.fn().mockResolvedValue(response), + summarize: vi.fn().mockResolvedValue(response), + }; + + const sdk = mockSdk(); + const kv = mockKV([existingNode, existingNode2], [existingEdge]); + registerTemporalGraphFunctions(sdk as never, kv as never, provider); + + const result = (await sdk.trigger("mem::temporal-graph-extract", { + observations: [ + { + id: "obs_1", + title: "Alice promotion", + narrative: "Alice was promoted to senior engineer at Acme Corp", + concepts: [], + files: [], + type: "conversation", + timestamp: "2025-01-01T00:00:00Z", + }, + ], + })) as { success: boolean; nodesAdded: number; edgesAdded: number }; + + expect(result.success).toBe(true); + + const allEdges = await kv.list<GraphEdge>("mem:graph:edges"); + expect(allEdges.length).toBe(2); + + const oldEdge = allEdges.find((e) => e.id === "ge_old"); + expect(oldEdge?.isLatest).toBe(false); + expect(oldEdge?.tvalidEnd).toBeDefined(); + + const newEdge = allEdges.find((e) => e.id !== "ge_old"); + expect(newEdge?.isLatest).toBe(true); + expect(newEdge?.version).toBe(2); + expect(newEdge?.tvalid).toBe("2025-01-01"); + }); + + it("temporal query returns current state", async () => { + const { registerTemporalGraphFunctions } = await import( + "../src/functions/temporal-graph.js" + ); + + const node: GraphNode = { + id: "gn_1", + type: "person", + name: "Bob", + properties: {}, + sourceObservationIds: ["obs_1"], + createdAt: "2024-01-01T00:00:00Z", + }; + const edge1: GraphEdge = { + id: "ge_1", + type: "located_in" as any, + sourceNodeId: "gn_1", + targetNodeId: "gn_2", + weight: 0.9, + sourceObservationIds: ["obs_1"], + createdAt: "2023-01-01T00:00:00Z", + tcommit: "2023-01-01T00:00:00Z", + tvalid: "2023-01-01", + tvalidEnd: "2024-06-01", + version: 1, + isLatest: false, + }; + const edge2: GraphEdge = { + id: "ge_2", + type: "located_in" as any, + sourceNodeId: "gn_1", + targetNodeId: "gn_3", + weight: 0.9, + sourceObservationIds: ["obs_2"], + createdAt: "2024-06-01T00:00:00Z", + tcommit: "2024-06-01T00:00:00Z", + tvalid: "2024-06-01", + version: 2, + isLatest: true, + }; + + const sdk = mockSdk(); + const kv = mockKV([node], [edge1, edge2]); + const provider: MemoryProvider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), + }; + registerTemporalGraphFunctions(sdk as never, kv as never, provider); + + const result = (await sdk.trigger("mem::temporal-query", { + entityName: "Bob", + })) as any; + + expect(result.entity).toBeDefined(); + expect(result.entity.name).toBe("Bob"); + expect(result.currentEdges.length).toBe(1); + expect(result.currentEdges[0].id).toBe("ge_2"); + }); + + it("temporal query with asOf returns historical state", async () => { + const { registerTemporalGraphFunctions } = await import( + "../src/functions/temporal-graph.js" + ); + + const node: GraphNode = { + id: "gn_1", + type: "person", + name: "Charlie", + properties: {}, + sourceObservationIds: ["obs_1"], + createdAt: "2023-01-01T00:00:00Z", + }; + const edge1: GraphEdge = { + id: "ge_1", + type: "located_in" as any, + sourceNodeId: "gn_1", + targetNodeId: "gn_nyc", + weight: 0.9, + sourceObservationIds: ["obs_1"], + createdAt: "2023-01-01T00:00:00Z", + tcommit: "2023-01-01T00:00:00Z", + tvalid: "2023-01-01", + tvalidEnd: "2024-06-01", + version: 1, + isLatest: false, + }; + const edge2: GraphEdge = { + id: "ge_2", + type: "located_in" as any, + sourceNodeId: "gn_1", + targetNodeId: "gn_london", + weight: 0.9, + sourceObservationIds: ["obs_2"], + createdAt: "2024-06-01T00:00:00Z", + tcommit: "2024-06-01T00:00:00Z", + tvalid: "2024-06-01", + version: 2, + isLatest: true, + }; + + const sdk = mockSdk(); + const kv = mockKV([node], [edge1, edge2]); + const provider: MemoryProvider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), + }; + registerTemporalGraphFunctions(sdk as never, kv as never, provider); + + const result = (await sdk.trigger("mem::temporal-query", { + entityName: "Charlie", + asOf: "2023-06-01T00:00:00Z", + })) as any; + + expect(result.entity.name).toBe("Charlie"); + expect(result.currentEdges.length).toBe(1); + expect(result.currentEdges[0].targetNodeId).toBe("gn_nyc"); + }); + + it("handles empty observations gracefully", async () => { + const { registerTemporalGraphFunctions } = await import( + "../src/functions/temporal-graph.js" + ); + const sdk = mockSdk(); + const kv = mockKV(); + const provider: MemoryProvider = { + name: "test", + compress: vi.fn(), + summarize: vi.fn(), + }; + registerTemporalGraphFunctions(sdk as never, kv as never, provider); + + const result = (await sdk.trigger("mem::temporal-graph-extract", { + observations: [], + })) as any; + + expect(result.success).toBe(false); + expect(result.error).toBe("No observations provided"); + }); +}); diff --git a/test/timeline.test.ts b/test/timeline.test.ts new file mode 100644 index 0000000..22273bc --- /dev/null +++ b/test/timeline.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerTimelineFunction } from "../src/functions/timeline.js"; +import type { CompressedObservation, Session, TimelineEntry } from "../src/types.js"; + +function mockKV() { + const store = new Map<string, Map<string, unknown>>(); + return { + get: async <T>(scope: string, key: string): Promise<T | null> => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async <T>(scope: string, key: string, data: T): Promise<T> => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise<void> => { + store.get(scope)?.delete(key); + }, + list: async <T>(scope: string): Promise<T[]> => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map<string, Function>(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (idOrInput: string | { function_id: string; payload: unknown }, data?: unknown) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + }; +} + +function makeObs( + id: string, + timestamp: string, + title: string, +): CompressedObservation { + return { + id, + sessionId: "ses_1", + timestamp, + type: "file_edit", + title, + facts: [], + narrative: title, + concepts: [], + files: [], + importance: 5, + }; +} + +describe("Timeline Function", () => { + let sdk: ReturnType<typeof mockSdk>; + let kv: ReturnType<typeof mockKV>; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerTimelineFunction(sdk as never, kv as never); + + const session: Session = { + id: "ses_1", + project: "my-project", + cwd: "/tmp", + startedAt: "2026-02-01T00:00:00Z", + status: "completed", + observationCount: 5, + }; + await kv.set("mem:sessions", "ses_1", session); + + await kv.set("mem:obs:ses_1", "obs_1", makeObs("obs_1", "2026-02-01T10:00:00Z", "First edit")); + await kv.set("mem:obs:ses_1", "obs_2", makeObs("obs_2", "2026-02-01T11:00:00Z", "Second edit")); + await kv.set("mem:obs:ses_1", "obs_3", makeObs("obs_3", "2026-02-01T12:00:00Z", "Third edit")); + await kv.set("mem:obs:ses_1", "obs_4", makeObs("obs_4", "2026-02-01T13:00:00Z", "Fourth edit")); + await kv.set("mem:obs:ses_1", "obs_5", makeObs("obs_5", "2026-02-01T14:00:00Z", "Fifth edit")); + }); + + it("anchors by ISO date and returns surrounding observations", async () => { + const result = (await sdk.trigger("mem::timeline", { + anchor: "2026-02-01T12:00:00Z", + before: 2, + after: 2, + })) as { entries: TimelineEntry[] }; + + expect(result.entries.length).toBe(5); + expect(result.entries[0].observation.id).toBe("obs_1"); + expect(result.entries[4].observation.id).toBe("obs_5"); + }); + + it("relativePosition is correct relative to anchor", async () => { + const result = (await sdk.trigger("mem::timeline", { + anchor: "2026-02-01T12:00:00Z", + before: 2, + after: 2, + })) as { entries: TimelineEntry[] }; + + const positions = result.entries.map((e) => e.relativePosition); + expect(positions).toEqual([-2, -1, 0, 1, 2]); + }); + + it("respects before and after limits", async () => { + const result = (await sdk.trigger("mem::timeline", { + anchor: "2026-02-01T12:00:00Z", + before: 1, + after: 1, + })) as { entries: TimelineEntry[] }; + + expect(result.entries.length).toBe(3); + expect(result.entries[0].observation.id).toBe("obs_2"); + expect(result.entries[2].observation.id).toBe("obs_4"); + }); + + it("returns empty entries when no sessions exist for project", async () => { + const result = (await sdk.trigger("mem::timeline", { + anchor: "2026-02-01T12:00:00Z", + project: "nonexistent-project", + })) as { entries: TimelineEntry[] }; + + expect(result.entries.length).toBe(0); + }); + + it("handles keyword anchor by finding matching observation", async () => { + const result = (await sdk.trigger("mem::timeline", { + anchor: "Third", + before: 1, + after: 1, + })) as { entries: TimelineEntry[] }; + + expect(result.entries.length).toBe(3); + const titles = result.entries.map((e) => e.observation.title); + expect(titles).toContain("Third edit"); + }); +}); diff --git a/test/tool-count-consistency.test.ts b/test/tool-count-consistency.test.ts new file mode 100644 index 0000000..6e845df --- /dev/null +++ b/test/tool-count-consistency.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { getAllTools, ESSENTIAL_TOOLS } from "../src/mcp/tools-registry.js"; + +const ROOT = join(import.meta.dirname, ".."); +const EXPECTED_TOOL_COUNT = 53; + +function readText(relativePath: string): string { + return readFileSync(join(ROOT, relativePath), "utf-8"); +} + +describe("Tool count consistency", () => { + it("registry exposes the expected number of tools", () => { + expect(getAllTools().length).toBe(EXPECTED_TOOL_COUNT); + }); + + it("cli help derives the tool counts from the registry", () => { + const cli = readText("src/cli.ts"); + expect(cli).toContain("const ALL_TOOLS_COUNT = getAllTools().length;"); + expect(cli).toContain( + "(default: all = ${ALL_TOOLS_COUNT} tools; core = ${CORE_TOOLS_COUNT} essentials)", + ); + expect(cli).not.toMatch(/all\s*=\s*51 tools/); + }); + + it("core tool count derives from the registry", () => { + const coreCount = getAllTools().filter((t) => ESSENTIAL_TOOLS.has(t.name)).length; + expect(coreCount).toBe(ESSENTIAL_TOOLS.size); + expect(coreCount).toBeGreaterThan(0); + }); + + it("README advertises the same tool count as the registry", () => { + const readme = readText("README.md"); + expect(readme).toContain(`${EXPECTED_TOOL_COUNT} MCP tools`); + expect(readme).not.toContain("51 MCP tools"); + }); + + it("skill count claims match the plugin/skills directory", () => { + const skillCount = readdirSync(join(ROOT, "plugin", "skills"), { + withFileTypes: true, + }).filter((e) => e.isDirectory() && e.name !== "_shared").length; + expect(readText("src/cli/connect/index.ts")).toContain(`${skillCount} skills`); + expect(readText("README.md")).toContain(`${skillCount} skills`); + expect(readText("AGENTS.md")).toContain(`12 hooks, ${skillCount} skills`); + expect(readText("plugin/plugin.json")).toContain(`${skillCount} skills`); + }); + + it("INSTALL_FOR_AGENTS.md names the real core tool set", () => { + const names = [...ESSENTIAL_TOOLS].map((t) => + t.replace(/^memory_/, "").replace(/_/g, " "), + ); + const sentence = `The ${names.length} core tools cover ${names + .slice(0, -1) + .join(", ")}, and ${names[names.length - 1]}.`; + expect(readText("INSTALL_FOR_AGENTS.md")).toContain(sentence); + }); +}); diff --git a/test/vector-index-dimensions.test.ts b/test/vector-index-dimensions.test.ts new file mode 100644 index 0000000..140d7f8 --- /dev/null +++ b/test/vector-index-dimensions.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { VectorIndex } from "../src/state/vector-index.js"; +import { migrateVectorIndex } from "../src/functions/migrate-vector-index.js"; +import type { EmbeddingProvider } from "../src/types.js"; + +describe("VectorIndex.validateDimensions", () => { + it("reports no mismatches and an empty dim set on an empty index", () => { + const result = new VectorIndex().validateDimensions(384); + expect(result.mismatches).toEqual([]); + expect(Array.from(result.seenDimensions)).toEqual([]); + }); + + it("reports no mismatches when every vector matches the expected dimension", () => { + const idx = new VectorIndex(); + idx.add("o1", "s1", new Float32Array(384)); + idx.add("o2", "s1", new Float32Array(384)); + const result = idx.validateDimensions(384); + expect(result.mismatches).toEqual([]); + expect(Array.from(result.seenDimensions)).toEqual([384]); + }); + + it("reports every wrong-dimension vector, not just the first", () => { + const idx = new VectorIndex(); + idx.add("good1", "s1", new Float32Array(384)); + idx.add("bad1", "s1", new Float32Array(1536)); + idx.add("good2", "s1", new Float32Array(384)); + idx.add("bad2", "s1", new Float32Array(768)); + const result = idx.validateDimensions(384); + expect(result.mismatches).toHaveLength(2); + expect(result.mismatches.map((m) => m.obsId).sort()).toEqual(["bad1", "bad2"]); + expect(Array.from(result.seenDimensions).sort((a, b) => a - b)).toEqual([ + 384, 768, 1536, + ]); + }); + + it("flags every entry when the entire index has the wrong dimension", () => { + const idx = new VectorIndex(); + idx.add("o1", "s1", new Float32Array(384)); + idx.add("o2", "s1", new Float32Array(384)); + const result = idx.validateDimensions(1536); + expect(result.mismatches).toHaveLength(2); + expect(Array.from(result.seenDimensions)).toEqual([384]); + }); +}); + +describe("migrateVectorIndex", () => { + const newProvider: EmbeddingProvider = { + name: "test-4d", + dimensions: 4, + embed: async (_text: string) => new Float32Array([0.1, 0.2, 0.3, 0.4]), + embedBatch: async (_texts: string[]) => + _texts.map(() => new Float32Array([0.1, 0.2, 0.3, 0.4])), + }; + + function mockKV() { + const store = new Map<string, Map<string, unknown>>(); + return { + get: async <T>(scope: string, key: string): Promise<T | null> => + (store.get(scope)?.get(key) as T) ?? null, + set: async <T>(scope: string, key: string, data: T): Promise<T> => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (_scope: string, _key: string): Promise<void> => {}, + list: async <T>(scope: string): Promise<T[]> => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; + } + + it("re-embeds observations with new provider dimensions", async () => { + const kv = mockKV(); + await kv.set("mem:sessions", "ses_1", { id: "ses_1" }); + await kv.set("mem:obs:ses_1", "obs_1", { + id: "obs_1", + sessionId: "ses_1", + timestamp: new Date().toISOString(), + type: "decision", + title: "migration test", + facts: ["test"], + narrative: "Testing migration", + concepts: ["test"], + files: [], + importance: 5, + }); + + const result = await migrateVectorIndex(kv as never, newProvider); + expect(result.success).toBe(true); + expect(result.totalProcessed).toBe(1); + expect(result.vectorSize).toBe(1); + expect(result.failed).toBe(0); + }); + + it("re-embeds memories with new provider dimensions", async () => { + const kv = mockKV(); + await kv.set("mem:memories", "mem_1", { + id: "mem_1", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: "fact", + title: "migration memory", + content: "This memory will be re-embedded", + concepts: ["test"], + files: [], + sessionIds: ["ses_1"], + strength: 7, + version: 1, + isLatest: true, + }); + + const result = await migrateVectorIndex(kv as never, newProvider); + expect(result.totalProcessed).toBe(1); + expect(result.vectorSize).toBe(1); + }); + + it("handles empty KV gracefully", async () => { + const kv = mockKV(); + + const result = await migrateVectorIndex(kv as never, newProvider); + expect(result.success).toBe(true); + expect(result.totalProcessed).toBe(0); + expect(result.vectorSize).toBe(0); + expect(result.failed).toBe(0); + }); +}); diff --git a/test/vector-index-populate.test.ts b/test/vector-index-populate.test.ts new file mode 100644 index 0000000..936232a --- /dev/null +++ b/test/vector-index-populate.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/keyed-mutex.js", () => ({ + withKeyedLock: <T>(_key: string, fn: () => Promise<T>) => fn(), +})); + +import { registerRememberFunction } from "../src/functions/remember.js"; +import { setVectorIndex, setEmbeddingProvider, getVectorIndex } from "../src/functions/search.js"; +import { VectorIndex } from "../src/state/vector-index.js"; +import type { EmbeddingProvider } from "../src/types.js"; + +function mockKV() { + const store = new Map<string, Map<string, unknown>>(); + return { + get: async <T>(scope: string, key: string): Promise<T | null> => + (store.get(scope)?.get(key) as T) ?? null, + set: async <T>(scope: string, key: string, data: T): Promise<T> => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise<void> => { + store.get(scope)?.delete(key); + }, + list: async <T>(scope: string): Promise<T[]> => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map<string, Function>(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async (input: { function_id: string; payload: unknown }) => { + const fn = functions.get(input.function_id); + if (!fn) throw new Error(`unknown fn ${input.function_id}`); + return fn(input.payload); + }, + }; +} + +describe("vector index population on remember", () => { + const mockEmbedder: EmbeddingProvider = { + name: "test", + dimensions: 3, + embed: async (_text: string) => new Float32Array([0.1, 0.2, 0.3]), + embedBatch: async (_texts: string[]) => + _texts.map(() => new Float32Array([0.1, 0.2, 0.3])), + }; + + let vectorIndex: VectorIndex; + + beforeEach(() => { + vectorIndex = new VectorIndex(); + setVectorIndex(vectorIndex); + setEmbeddingProvider(mockEmbedder); + }); + + afterEach(() => { + setVectorIndex(null); + setEmbeddingProvider(null); + }); + + it("calls vectorIndex.add() when remember saves a memory", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "Test memory for vector indexing", type: "fact" }, + }); + + expect((result as { success: boolean }).success).toBe(true); + expect(vectorIndex.size).toBe(1); + }); + + it("calls vectorIndex.add() with short content (0% similarity dedup)", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "First unique memory", type: "fact" }, + }); + await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "Second completely different memory", type: "fact" }, + }); + + expect(vectorIndex.size).toBe(2); + }); + + it("handles missing embedder gracefully (vectorIndex stays null)", async () => { + // Override beforeEach setup: this case wants null state explicitly. + setVectorIndex(null); + setEmbeddingProvider(null); + + const sdk = mockSdk(); + const kv = mockKV(); + registerRememberFunction(sdk as never, kv as never); + + const result = await sdk.trigger({ + function_id: "mem::remember", + payload: { content: "This should work without vector index", type: "fact" }, + }); + + expect((result as { success: boolean }).success).toBe(true); + expect(getVectorIndex()).toBeNull(); + }); +}); diff --git a/test/vector-index.test.ts b/test/vector-index.test.ts new file mode 100644 index 0000000..317f4f0 --- /dev/null +++ b/test/vector-index.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { VectorIndex } from "../src/state/vector-index.js"; + +describe("VectorIndex", () => { + let index: VectorIndex; + + beforeEach(() => { + index = new VectorIndex(); + }); + + it("starts empty", () => { + expect(index.size).toBe(0); + }); + + it("adds and retrieves vectors", () => { + index.add("obs_1", "ses_1", new Float32Array([0.1, 0.2, 0.3])); + expect(index.size).toBe(1); + }); + + it("removes a vector", () => { + index.add("obs_1", "ses_1", new Float32Array([0.1, 0.2, 0.3])); + index.remove("obs_1"); + expect(index.size).toBe(0); + }); + + it("returns empty array when searching empty index", () => { + const results = index.search(new Float32Array([0.1, 0.2, 0.3])); + expect(results).toEqual([]); + }); + + it("returns results sorted by cosine similarity", () => { + index.add("obs_close", "ses_1", new Float32Array([1, 0, 0])); + index.add("obs_far", "ses_1", new Float32Array([0, 1, 0])); + index.add("obs_medium", "ses_1", new Float32Array([0.7, 0.7, 0])); + + const results = index.search(new Float32Array([1, 0, 0])); + expect(results[0].obsId).toBe("obs_close"); + expect(results[0].score).toBeCloseTo(1.0, 5); + expect(results[1].obsId).toBe("obs_medium"); + expect(results[2].obsId).toBe("obs_far"); + expect(results[2].score).toBeCloseTo(0.0, 5); + }); + + it("respects the limit parameter", () => { + for (let i = 0; i < 10; i++) { + index.add(`obs_${i}`, "ses_1", new Float32Array([i * 0.1, 0.5, 0.5])); + } + const results = index.search(new Float32Array([0.9, 0.5, 0.5]), 3); + expect(results.length).toBe(3); + }); + + it("clears all vectors", () => { + index.add("obs_1", "ses_1", new Float32Array([0.1, 0.2, 0.3])); + index.add("obs_2", "ses_1", new Float32Array([0.4, 0.5, 0.6])); + index.clear(); + expect(index.size).toBe(0); + expect(index.search(new Float32Array([0.1, 0.2, 0.3]))).toEqual([]); + }); + + it("serialize and deserialize round-trip preserves data", () => { + index.add("obs_1", "ses_1", new Float32Array([0.1, 0.2, 0.3])); + index.add("obs_2", "ses_2", new Float32Array([0.4, 0.5, 0.6])); + + const json = index.serialize(); + const restored = VectorIndex.deserialize(json); + + expect(restored.size).toBe(2); + const results = restored.search(new Float32Array([0.1, 0.2, 0.3]), 2); + expect(results.length).toBe(2); + expect(results[0].obsId).toBe("obs_1"); + expect(results[0].sessionId).toBe("ses_1"); + }); + + it("handles zero vectors without error", () => { + index.add("obs_zero", "ses_1", new Float32Array([0, 0, 0])); + const results = index.search(new Float32Array([1, 0, 0])); + expect(results[0].score).toBe(0); + }); + + it("round-trip preserves dim + identity for pooled-Buffer sizes (#587)", () => { + // 384-dim floats = 1536 bytes, comfortably inside Node's 8KB Buffer + // pool. Without explicit byteOffset/byteLength in the base64 round-trip, + // deserialise reads pool offset 0 and reports the entire pool as a + // 2048-element view, which the live index then rejects with + // "dimensions seen on disk: 2048". + const DIM = 384; + const vecs = Array.from({ length: 5 }, (_, n) => { + const v = new Float32Array(DIM); + for (let i = 0; i < DIM; i++) v[i] = n * 1000 + i; + return v; + }); + vecs.forEach((v, n) => index.add(`obs_${n}`, "ses_1", v)); + + const restored = VectorIndex.deserialize(index.serialize()); + expect(restored.size).toBe(5); + const { mismatches } = restored.validateDimensions(DIM); + expect(mismatches).toEqual([]); + for (let n = 0; n < 5; n++) { + const results = restored.search(vecs[n], 1); + expect(results[0].obsId).toBe(`obs_${n}`); + expect(results[0].score).toBeCloseTo(1.0, 4); + } + }); + + it("preserves bytes when source Float32Array is itself a sliced view (#587)", () => { + // The encode side has the same risk: passing arr.buffer drops the + // slice metadata if arr is a sub-view (subarray / typedArray.set). + const backing = new Float32Array(8); + for (let i = 0; i < 8; i++) backing[i] = i; + const slice = backing.subarray(2, 6); // values 2, 3, 4, 5 + + index.add("obs_slice", "ses_1", slice); + const restored = VectorIndex.deserialize(index.serialize()); + const results = restored.search(new Float32Array([2, 3, 4, 5]), 1); + expect(results[0].obsId).toBe("obs_slice"); + expect(results[0].score).toBeCloseTo(1.0, 4); + }); +}); diff --git a/test/verify.test.ts b/test/verify.test.ts new file mode 100644 index 0000000..9af433c --- /dev/null +++ b/test/verify.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { registerVerifyFunction } from "../src/functions/verify.js"; +import type { Memory, CompressedObservation, Session } from "../src/types.js"; +import { mockKV, mockSdk } from "./helpers/mocks.js"; + +describe("Verify Function", () => { + let sdk: ReturnType<typeof mockSdk>; + let kv: ReturnType<typeof mockKV>; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + vi.clearAllMocks(); + registerVerifyFunction(sdk as never, kv as never); + }); + + it("returns error when id is missing", async () => { + const result = (await sdk.trigger("mem::verify", {})) as { + success: boolean; + error: string; + }; + expect(result.success).toBe(false); + expect(result.error).toBe("id is required"); + }); + + it("returns not found for unknown id", async () => { + const result = (await sdk.trigger("mem::verify", { + id: "unknown_123", + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error).toBe("not found"); + }); + + it("verifies a memory with citation chain", async () => { + const session: Session = { + id: "ses_1", + project: "/test/project", + cwd: "/test", + startedAt: "2026-03-01T00:00:00Z", + status: "completed", + observationCount: 2, + }; + await kv.set("mem:sessions", "ses_1", session); + + const obs: CompressedObservation = { + id: "obs_1", + sessionId: "ses_1", + timestamp: "2026-03-01T00:01:00Z", + type: "decision", + title: "Chose React over Vue", + facts: ["React chosen for ecosystem"], + narrative: "Team decided on React", + concepts: ["react", "frontend"], + files: ["src/App.tsx"], + importance: 8, + confidence: 0.85, + }; + await kv.set("mem:obs:ses_1", "obs_1", obs); + + const memory: Memory = { + id: "mem_1", + createdAt: "2026-03-01T00:02:00Z", + updatedAt: "2026-03-01T00:02:00Z", + type: "architecture", + title: "Using React for frontend", + content: "The team uses React for the frontend framework", + concepts: ["react", "frontend"], + files: ["src/App.tsx"], + sessionIds: ["ses_1"], + strength: 8, + version: 1, + isLatest: true, + sourceObservationIds: ["obs_1"], + }; + await kv.set("mem:memories", "mem_1", memory); + + const result = (await sdk.trigger("mem::verify", { id: "mem_1" })) as { + success: boolean; + type: string; + citations: Array<{ + observationId: string; + confidence: number; + sessionProject: string; + }>; + citationCount: number; + }; + + expect(result.success).toBe(true); + expect(result.type).toBe("memory"); + expect(result.citationCount).toBe(1); + expect(result.citations[0].observationId).toBe("obs_1"); + expect(result.citations[0].confidence).toBe(0.85); + expect(result.citations[0].sessionProject).toBe("/test/project"); + }); + + it("verifies a memory with no source observations", async () => { + const memory: Memory = { + id: "mem_2", + createdAt: "2026-03-01T00:00:00Z", + updatedAt: "2026-03-01T00:00:00Z", + type: "fact", + title: "API uses REST", + content: "The API follows REST conventions", + concepts: ["api", "rest"], + files: [], + sessionIds: [], + strength: 5, + version: 1, + isLatest: true, + }; + await kv.set("mem:memories", "mem_2", memory); + + const result = (await sdk.trigger("mem::verify", { id: "mem_2" })) as { + success: boolean; + type: string; + citationCount: number; + citations: unknown[]; + }; + + expect(result.success).toBe(true); + expect(result.type).toBe("memory"); + expect(result.citationCount).toBe(0); + expect(result.citations).toEqual([]); + }); + + it("verifies an observation directly", async () => { + const session: Session = { + id: "ses_2", + project: "/my/project", + cwd: "/my", + startedAt: "2026-03-01T00:00:00Z", + status: "active", + observationCount: 1, + }; + await kv.set("mem:sessions", "ses_2", session); + + const obs: CompressedObservation = { + id: "obs_direct", + sessionId: "ses_2", + timestamp: "2026-03-01T00:01:00Z", + type: "file_write", + title: "Created index.ts", + facts: ["Created file"], + narrative: "Agent created the index file", + concepts: ["typescript"], + files: ["index.ts"], + importance: 6, + confidence: 0.72, + }; + await kv.set("mem:obs:ses_2", "obs_direct", obs); + + const result = (await sdk.trigger("mem::verify", { + id: "obs_direct", + })) as { + success: boolean; + type: string; + observation: { id: string; confidence: number }; + session: { project: string }; + }; + + expect(result.success).toBe(true); + expect(result.type).toBe("observation"); + expect(result.observation.id).toBe("obs_direct"); + expect(result.observation.confidence).toBe(0.72); + expect(result.session.project).toBe("/my/project"); + }); + + it("returns memory info with supersede chain", async () => { + const memory: Memory = { + id: "mem_v2", + createdAt: "2026-03-02T00:00:00Z", + updatedAt: "2026-03-02T00:00:00Z", + type: "pattern", + title: "Updated pattern", + content: "Updated pattern content", + concepts: ["testing"], + files: [], + sessionIds: [], + strength: 9, + version: 2, + parentId: "mem_v1", + supersedes: ["mem_v1"], + isLatest: true, + }; + await kv.set("mem:memories", "mem_v2", memory); + + const result = (await sdk.trigger("mem::verify", { id: "mem_v2" })) as { + success: boolean; + memory: { + id: string; + version: number; + parentId: string; + supersedes: string[]; + }; + }; + + expect(result.success).toBe(true); + expect(result.memory.version).toBe(2); + expect(result.memory.parentId).toBe("mem_v1"); + expect(result.memory.supersedes).toEqual(["mem_v1"]); + }); +}); diff --git a/test/viewer-graph-cooldown.test.ts b/test/viewer-graph-cooldown.test.ts new file mode 100644 index 0000000..267301f --- /dev/null +++ b/test/viewer-graph-cooldown.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// viewer graph kept bouncing on >1000 nodes because damping +// alone could not bleed off the per-frame force pile-up. Cool-down +// adds tick-decayed damping, a per-node velocity cap, and parks the +// raf loop when total kinetic energy drops below an epsilon. +describe("viewer graph cool-down", () => { + const viewer = readFileSync("src/viewer/index.html", "utf-8"); + + it("tracks a tickCount that grows each simulation step", () => { + expect(viewer).toMatch(/graphSim\.tickCount\s*=\s*\(graphSim\.tickCount\s*\|\|\s*0\)\s*\+\s*1/); + }); + + it("adds tick-decay to damping (coolBoost scales with tickCount)", () => { + expect(viewer).toMatch(/coolBoost\s*=\s*Math\.min\(0\.4,\s*graphSim\.tickCount\s*\/\s*1500\)/); + expect(viewer).toMatch(/damping\s*=\s*0\.9\s*-\s*coolBoost/); + }); + + it("caps per-node velocity by node-count band", () => { + expect(viewer).toMatch( + /velocityCap\s*=\s*nodeCount\s*>\s*1000\s*\?\s*6/, + ); + expect(viewer).toMatch(/nvx\s*>\s*velocityCap/); + expect(viewer).toMatch(/nvy\s*>\s*velocityCap/); + }); + + it("parks the raf loop once the layout is quiet for 30 ticks", () => { + expect(viewer).toMatch(/rmsVelocity/); + expect(viewer).toMatch(/quietTicks/); + expect(viewer).toMatch(/if\s*\(graphSim\.quietTicks\s*>\s*30\)/); + }); + + it("wakes the parked loop on mousedown so drag still responds", () => { + expect(viewer).toMatch(/graphSim\.quietTicks\s*=\s*0/); + expect(viewer).toMatch(/if\s*\(graphSim\.running\s*&&\s*!graphSim\.raf\)/); + }); +}); diff --git a/test/viewer-host.test.ts b/test/viewer-host.test.ts new file mode 100644 index 0000000..b68ecec --- /dev/null +++ b/test/viewer-host.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + buildAllowedHosts, + isLoopbackHost, + requireInboundBearer, + resolveViewerHost, + startViewerServer, + ViewerConfigError, +} from "../src/viewer/server.js"; + +describe("resolveViewerHost", () => { + const originalEnv = process.env.AGENTMEMORY_VIEWER_HOST; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.AGENTMEMORY_VIEWER_HOST; + } else { + process.env.AGENTMEMORY_VIEWER_HOST = originalEnv; + } + }); + + it("defaults to 127.0.0.1 when AGENTMEMORY_VIEWER_HOST is unset", () => { + delete process.env.AGENTMEMORY_VIEWER_HOST; + expect(resolveViewerHost()).toBe("127.0.0.1"); + }); + + it("defaults to 127.0.0.1 when AGENTMEMORY_VIEWER_HOST is empty", () => { + process.env.AGENTMEMORY_VIEWER_HOST = ""; + expect(resolveViewerHost()).toBe("127.0.0.1"); + }); + + it("returns the configured value when AGENTMEMORY_VIEWER_HOST is set", () => { + process.env.AGENTMEMORY_VIEWER_HOST = "::"; + expect(resolveViewerHost()).toBe("::"); + }); + + it("trims surrounding whitespace", () => { + process.env.AGENTMEMORY_VIEWER_HOST = " ::1 "; + expect(resolveViewerHost()).toBe("::1"); + }); +}); + +describe("isLoopbackHost", () => { + it.each([ + ["127.0.0.1", true], + ["::1", true], + ["localhost", true], + [" 127.0.0.1 ", true], + ["LOCALHOST", true], + ["::", false], + ["0.0.0.0", false], + ["10.0.0.1", false], + ["fly-local-6pn", false], + ])("classifies %s as loopback=%s", (host, expected) => { + expect(isLoopbackHost(host)).toBe(expected); + }); +}); + +describe("buildAllowedHosts", () => { + const originalOverride = process.env.VIEWER_ALLOWED_HOSTS; + + afterEach(() => { + if (originalOverride === undefined) { + delete process.env.VIEWER_ALLOWED_HOSTS; + } else { + process.env.VIEWER_ALLOWED_HOSTS = originalOverride; + } + }); + + it("seeds loopback defaults and CORS origins when bind is loopback", () => { + delete process.env.VIEWER_ALLOWED_HOSTS; + const allowed = buildAllowedHosts( + ["http://localhost:3111", "http://127.0.0.1:3111"], + 3113, + "127.0.0.1", + ); + expect(allowed.has("localhost:3113")).toBe(true); + expect(allowed.has("127.0.0.1:3113")).toBe(true); + expect(allowed.has("[::1]:3113")).toBe(true); + expect(allowed.has("localhost:3111")).toBe(true); + expect(allowed.has("127.0.0.1:3111")).toBe(true); + }); + + it("drops loopback defaults when bind is non-loopback, leaving only VIEWER_ALLOWED_HOSTS", () => { + process.env.VIEWER_ALLOWED_HOSTS = "viewer.example.com,localhost:3113"; + const allowed = buildAllowedHosts( + ["http://localhost:3111", "http://127.0.0.1:3111"], + 3113, + "::", + ); + expect(allowed.has("viewer.example.com")).toBe(true); + expect(allowed.has("localhost:3113")).toBe(true); // came in via the override + // Origin-derived loopback hostnames must not silently land in the + // allowlist when bind is non-loopback — otherwise Host header + // spoofing reopens the very gap the override is meant to close. + expect(allowed.has("localhost:3111")).toBe(false); + expect(allowed.has("127.0.0.1:3111")).toBe(false); + expect(allowed.has("127.0.0.1:3113")).toBe(false); + expect(allowed.has("[::1]:3113")).toBe(false); + }); + + it("returns an empty set when bind is non-loopback and the override is empty", () => { + delete process.env.VIEWER_ALLOWED_HOSTS; + const allowed = buildAllowedHosts( + ["http://localhost:3111"], + 3113, + "0.0.0.0", + ); + expect(allowed.size).toBe(0); + }); +}); + +describe("requireInboundBearer", () => { + const secret = "s3cr3t-bearer-value"; + + it("accepts a matching Bearer token", () => { + expect(requireInboundBearer(`Bearer ${secret}`, secret)).toBe(true); + }); + + it("accepts case-insensitive scheme", () => { + expect(requireInboundBearer(`bearer ${secret}`, secret)).toBe(true); + }); + + it("rejects a mismatching Bearer token", () => { + expect(requireInboundBearer(`Bearer wrong-token`, secret)).toBe(false); + }); + + it("rejects a missing header", () => { + expect(requireInboundBearer(undefined, secret)).toBe(false); + }); + + it("rejects a non-Bearer scheme", () => { + expect(requireInboundBearer(`Basic ${secret}`, secret)).toBe(false); + }); + + it("rejects an array Authorization header", () => { + expect(requireInboundBearer([`Bearer ${secret}`], secret)).toBe(false); + }); +}); + +describe("startViewerServer host binding", () => { + const originalEnv = process.env.AGENTMEMORY_VIEWER_HOST; + const originalOverride = process.env.VIEWER_ALLOWED_HOSTS; + let server: Server | undefined; + let logSpy: ReturnType<typeof vi.spyOn>; + let warnSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(async () => { + if (server) { + await new Promise<void>((resolve) => server!.close(() => resolve())); + server = undefined; + } + logSpy.mockRestore(); + warnSpy.mockRestore(); + if (originalEnv === undefined) { + delete process.env.AGENTMEMORY_VIEWER_HOST; + } else { + process.env.AGENTMEMORY_VIEWER_HOST = originalEnv; + } + if (originalOverride === undefined) { + delete process.env.VIEWER_ALLOWED_HOSTS; + } else { + process.env.VIEWER_ALLOWED_HOSTS = originalOverride; + } + }); + + async function waitForListening(s: Server): Promise<void> { + if (s.listening) return; + await new Promise<void>((resolve) => s.once("listening", () => resolve())); + } + + it("binds to 127.0.0.1 by default — preserves loopback-only security", async () => { + delete process.env.AGENTMEMORY_VIEWER_HOST; + server = startViewerServer(0, null, null); + await waitForListening(server); + const addr = server.address() as AddressInfo; + expect(addr.address).toBe("127.0.0.1"); + }); + + it("binds to AGENTMEMORY_VIEWER_HOST when set — covers the deploy/fly fix for #434", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "::1"; + server = startViewerServer(0, null, null); + await waitForListening(server); + const addr = server.address() as AddressInfo; + expect(addr.address).toBe("::1"); + }); + + it("refuses to start when bind is non-loopback and no AGENTMEMORY_SECRET is configured", () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + process.env.VIEWER_ALLOWED_HOSTS = "viewer.example.com"; + expect(() => startViewerServer(0, null, null)).toThrow(ViewerConfigError); + expect(() => startViewerServer(0, null, null)).toThrow( + /unset AGENTMEMORY_VIEWER_HOST/, + ); + expect(() => startViewerServer(0, null, null)).toThrow( + /set AGENTMEMORY_SECRET/, + ); + }); + + it("refuses to start when bind is non-loopback and VIEWER_ALLOWED_HOSTS is empty", () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + delete process.env.VIEWER_ALLOWED_HOSTS; + expect(() => + startViewerServer(0, null, null, "test-secret"), + ).toThrow(ViewerConfigError); + expect(() => startViewerServer(0, null, null, "test-secret")).toThrow( + /set VIEWER_ALLOWED_HOSTS/, + ); + expect(() => startViewerServer(0, null, null, "test-secret")).toThrow( + /unset AGENTMEMORY_VIEWER_HOST/, + ); + }); + + it("returns 401 for non-Bearer API calls when bind is non-loopback", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + // Pre-seed an entry so refuse-start passes. The request-time + // buildAllowedHosts call will re-read the env, so we widen it after + // the port is known. + process.env.VIEWER_ALLOWED_HOSTS = "placeholder"; + const secret = "test-secret-xyz"; + server = startViewerServer(0, null, null, secret); + await waitForListening(server); + const addr = server.address() as AddressInfo; + process.env.VIEWER_ALLOWED_HOSTS = `127.0.0.1:${addr.port}`; + + const unauthed = await fetch( + `http://127.0.0.1:${addr.port}/agentmemory/livez`, + ); + expect(unauthed.status).toBe(401); + + const wrongBearer = await fetch( + `http://127.0.0.1:${addr.port}/agentmemory/livez`, + { headers: { Authorization: "Bearer wrong-token" } }, + ); + expect(wrongBearer.status).toBe(401); + + // Correct bearer reaches the proxy. Upstream is not running in + // this test, so we expect a non-401 status (the proxy will fail + // with 502/504) — the key invariant is that the auth gate let it + // through. + const goodBearer = await fetch( + `http://127.0.0.1:${addr.port}/agentmemory/livez`, + { headers: { Authorization: `Bearer ${secret}` } }, + ); + expect(goodBearer.status).not.toBe(401); + }); + + it("does not require inbound auth on the loopback default bind", async () => { + delete process.env.AGENTMEMORY_VIEWER_HOST; + delete process.env.VIEWER_ALLOWED_HOSTS; + server = startViewerServer(0, null, null, "test-secret-xyz"); + await waitForListening(server); + const addr = server.address() as AddressInfo; + + const res = await fetch( + `http://127.0.0.1:${addr.port}/agentmemory/livez`, + ); + // No 401: the loopback bind keeps the legacy behaviour where any + // local process is implicitly trusted. Upstream is not running, so + // we expect a proxy-error status, just not the auth gate. + expect(res.status).not.toBe(401); + }); + + it("serves HTML at / on non-loopback bind without requiring a Bearer", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + process.env.VIEWER_ALLOWED_HOSTS = "placeholder"; + server = startViewerServer(0, null, null, "test-secret-xyz"); + await waitForListening(server); + const addr = server.address() as AddressInfo; + process.env.VIEWER_ALLOWED_HOSTS = `127.0.0.1:${addr.port}`; + + const res = await fetch(`http://127.0.0.1:${addr.port}/`); + // The HTML shell stays unauthenticated so a browser can fetch it; + // the embedded JS still needs the bearer for the data calls. + expect(res.status).not.toBe(401); + }); + + it("logs non-loopback bind mode and inbound auth requirements", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + process.env.VIEWER_ALLOWED_HOSTS = "localhost:3113,[::1]:3113"; + server = startViewerServer(0, null, null, "test-secret-xyz"); + await waitForListening(server); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("bound to 0.0.0.0; inbound Bearer required"), + ); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("allowed Host headers: localhost:3113, [::1]:3113"), + ); + }); + + it("does not retry EADDRINUSE when bind is non-loopback", async () => { + process.env.AGENTMEMORY_VIEWER_HOST = "0.0.0.0"; + process.env.VIEWER_ALLOWED_HOSTS = "localhost:3113"; + + const blocker = createServer((_req, res) => res.end("busy")); + await new Promise<void>((resolve) => blocker.listen(0, "0.0.0.0", resolve)); + const blockedPort = (blocker.address() as AddressInfo).port; + + try { + const viewer = startViewerServer( + blockedPort, + null, + null, + "test-secret-xyz", + ); + const err = await new Promise<NodeJS.ErrnoException>((resolve) => + viewer.once("error", resolve), + ); + expect(err.code).toBe("EADDRINUSE"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(viewer.listening).toBe(false); + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining(`fallback from ${blockedPort}`), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("not retrying because non-loopback viewer binds"), + ); + } finally { + await new Promise<void>((resolve) => blocker.close(() => resolve())); + } + }); +}); diff --git a/test/viewer-memories-sort.test.ts b/test/viewer-memories-sort.test.ts new file mode 100644 index 0000000..865b641 --- /dev/null +++ b/test/viewer-memories-sort.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; + +// Viewer Memories tab used to render in KV-insertion order, hiding new +// entries at the bottom of long lists (#674). loadMemories() now sorts +// the response newest-first on `createdAt` (fallback `updatedAt`) before +// renderMemories sees it. Matches the pattern already used by Sessions +// and Metrics tabs which sort on `startedAt` desc via localeCompare. +describe("viewer Memories tab sorts newest first (#674)", () => { + const viewer = readFileSync("src/viewer/index.html", "utf-8"); + + it("loadMemories sorts items by createdAt desc before storing in state", () => { + expect(viewer).toMatch( + /loadMemories[\s\S]*?items\.sort\(function\(a,\s*b\)\s*\{[\s\S]*?bc\.localeCompare\(ac\)/, + ); + }); + + it("sort falls back to updatedAt when createdAt is missing", () => { + expect(viewer).toMatch( + /\(a && a\.createdAt\) \|\| \(a && a\.updatedAt\)/, + ); + expect(viewer).toMatch( + /\(b && b\.createdAt\) \|\| \(b && b\.updatedAt\)/, + ); + }); + + it("Memories sort mirrors the Sessions/Metrics localeCompare descending pattern", () => { + expect(viewer).toMatch( + /sessions\.sort\(function\(a, b\) \{ return \(b\.startedAt \|\| ''\)\.localeCompare\(a\.startedAt \|\| ''\); \}\)/, + ); + }); +}); diff --git a/test/viewer-security.test.ts b/test/viewer-security.test.ts new file mode 100644 index 0000000..ff56734 --- /dev/null +++ b/test/viewer-security.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, afterAll } from "vitest"; +import type { AddressInfo } from "node:net"; +import { request as httpRequest } from "node:http"; +import { renderViewerDocument } from "../src/viewer/document.js"; +import { + buildAllowedHosts, + isHostAllowed, + startViewerServer, +} from "../src/viewer/server.js"; + +describe("viewer document security", () => { + it("serves a nonce-backed CSP without unsafe-inline script execution", () => { + const rendered = renderViewerDocument(); + expect(rendered.found).toBe(true); + if (!rendered.found) return; + + expect(rendered.csp).toContain("script-src 'nonce-"); + expect(rendered.csp).toContain("script-src-attr 'none'"); + expect(rendered.csp).toContain("img-src 'self'"); + expect(rendered.csp).not.toContain("script-src 'unsafe-inline'"); + expect(rendered.html).toContain("<script nonce=\""); + expect(rendered.html).not.toContain("__AGENTMEMORY_VIEWER_NONCE__"); + }); + + it("does not loosen img-src with bare data: URI allowance (#447)", () => { + // #313 added `data:` so an inline-SVG favicon could load. #447 reverts + // that by self-hosting the favicon at /favicon.svg — `data:` would + // also allow any data:image/png;base64,... and (in some browsers) + // data:text/html;base64,..., which the viewer never needs. + const rendered = renderViewerDocument(); + expect(rendered.found).toBe(true); + if (!rendered.found) return; + + const directives = rendered.csp.split(";").map((d) => d.trim()); + const imgSrc = directives.find((d) => d.startsWith("img-src")); + expect(imgSrc).toBeDefined(); + expect(imgSrc).toBe("img-src 'self'"); + expect(imgSrc).not.toContain("data:"); + + // Favicon link in the HTML must reference the self-hosted file, not + // an inline data: URI — that's what lets the CSP stay tight. + expect(rendered.html).toContain('href="/favicon.svg"'); + expect(rendered.html).not.toContain("data:image/svg+xml"); + }); + + it("does not contain inline DOM event handlers", () => { + const rendered = renderViewerDocument(); + expect(rendered.found).toBe(true); + if (!rendered.found) return; + + expect(rendered.html).not.toContain("onclick="); + expect(rendered.html).not.toContain("onmouseover="); + expect(rendered.html).not.toContain("onmouseout="); + }); +}); + +describe("viewer host allowlist (DNS rebinding defence)", () => { + const DEFAULT_ORIGINS = [ + "http://localhost:3111", + "http://localhost:3113", + "http://127.0.0.1:3111", + "http://127.0.0.1:3113", + ]; + + it("accepts loopback host:port combinations the viewer is reachable at", () => { + const allowed = buildAllowedHosts(DEFAULT_ORIGINS, 3113); + expect(isHostAllowed("localhost:3113", allowed)).toBe(true); + expect(isHostAllowed("127.0.0.1:3113", allowed)).toBe(true); + expect(isHostAllowed("[::1]:3113", allowed)).toBe(true); + }); + + it("includes the rest-port host so the same origin list works for the REST server", () => { + const allowed = buildAllowedHosts(DEFAULT_ORIGINS, 3113); + expect(isHostAllowed("localhost:3111", allowed)).toBe(true); + expect(isHostAllowed("127.0.0.1:3111", allowed)).toBe(true); + }); + + it("rejects rebound attacker hostnames pointing at loopback", () => { + const allowed = buildAllowedHosts(DEFAULT_ORIGINS, 3113); + // Classic DNS-rebinding payload: attacker domain on the viewer port. + expect(isHostAllowed("attacker.com:3113", allowed)).toBe(false); + expect(isHostAllowed("evil.example:3113", allowed)).toBe(false); + // 0.0.0.0 is a routable loopback alias on Linux but not what the + // viewer prints; reject so an attacker can't substitute it. + expect(isHostAllowed("0.0.0.0:3113", allowed)).toBe(false); + }); + + it("rejects bare loopback Host headers without the listening port", () => { + const allowed = buildAllowedHosts(DEFAULT_ORIGINS, 3113); + // Curl-style `Host: localhost` (no port) does NOT match `localhost:3113`. + expect(isHostAllowed("localhost", allowed)).toBe(false); + expect(isHostAllowed("127.0.0.1", allowed)).toBe(false); + }); + + it("rejects missing, empty, or non-string Host headers", () => { + const allowed = buildAllowedHosts(DEFAULT_ORIGINS, 3113); + expect(isHostAllowed(undefined, allowed)).toBe(false); + expect(isHostAllowed("", allowed)).toBe(false); + expect(isHostAllowed(" ", allowed)).toBe(false); + // Node sets `host` to a string array only in very unusual setups; + // treat anything non-string as forbidden. + expect(isHostAllowed(["localhost:3113"] as unknown as string, allowed)) + .toBe(false); + }); + + it("is case-insensitive on hostname per RFC 3986 §3.2.2", () => { + const allowed = buildAllowedHosts(DEFAULT_ORIGINS, 3113); + expect(isHostAllowed("LOCALHOST:3113", allowed)).toBe(true); + expect(isHostAllowed("LocalHost:3113", allowed)).toBe(true); + }); + + it("honours operator-supplied VIEWER_ALLOWED_ORIGINS on the host check", () => { + const custom = buildAllowedHosts( + ["http://memory.internal:8080", "http://localhost:3113"], + 3113, + ); + expect(isHostAllowed("memory.internal:8080", custom)).toBe(true); + expect(isHostAllowed("memory.internal", custom)).toBe(false); + expect(isHostAllowed("attacker.com:3113", custom)).toBe(false); + }); + + it("ignores malformed origin entries instead of throwing", () => { + const allowed = buildAllowedHosts( + ["not-a-url", "", "http://localhost:3113"], + 3113, + ); + expect(isHostAllowed("localhost:3113", allowed)).toBe(true); + }); +}); + +describe("viewer request handler DNS rebinding defence (e2e)", () => { + const cleanups: Array<() => Promise<void>> = []; + afterAll(async () => { + for (const c of cleanups) await c(); + }); + + async function spinUpViewer(): Promise<{ port: number }> { + // Start on port 0 so the OS assigns a free port; passing a real port + // exercises buildAllowedHosts() with the live listen value. + const server = startViewerServer(0, {}, {}, undefined, 0); + await new Promise<void>((resolve) => server.once("listening", () => resolve())); + const addr = server.address() as AddressInfo; + cleanups.push( + () => new Promise<void>((resolve) => server.close(() => resolve())), + ); + return { port: addr.port }; + } + + // Use node:http directly — the global `fetch` (undici) silently + // overrides the Host header to the URL authority, so we cannot use it + // to simulate a DNS-rebinding payload that lands on 127.0.0.1 while + // carrying `Host: attacker.com`. + function request( + port: number, + hostHeader: string, + pathname = "/agentmemory/livez", + ): Promise<{ status: number; body: string; headers: Record<string, string | string[] | undefined> }> { + return new Promise((resolve, reject) => { + const req = httpRequest( + { + host: "127.0.0.1", + port, + path: pathname, + method: "GET", + headers: { Host: hostHeader }, + }, + (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + body, + headers: res.headers, + }); + }); + }, + ); + req.on("error", reject); + req.end(); + }); + } + + it("returns 403 on an attacker-controlled Host header (DNS rebinding payload)", async () => { + const { port } = await spinUpViewer(); + const res = await request(port, `attacker.com:${port}`); + expect(res.status).toBe(403); + expect(res.body).toContain("forbidden host"); + }); + + it("returns 403 even on the viewer landing page when Host is not loopback", async () => { + const { port } = await spinUpViewer(); + const res = await request(port, `evil.example:${port}`, "/"); + expect(res.status).toBe(403); + expect(res.body).toContain("forbidden host"); + }); + + it("accepts loopback Host headers and serves the viewer HTML", async () => { + const { port } = await spinUpViewer(); + const res = await request(port, `localhost:${port}`, "/"); + // 200 with the viewer HTML when the bundled template is resolvable, + // or 404 when running from source without `npm run build` having + // populated dist/viewer/. Either way it's NOT 403 — the host gate + // passed. The CSP nonce assertion below is the load-bearing check. + expect(res.status === 200 || res.status === 404).toBe(true); + if (res.status === 200) { + expect(res.body).toContain("agentmemory viewer"); + } + }); + + it("serves /favicon.svg with image/svg+xml so the tight CSP can drop data: (#447)", async () => { + const { port } = await spinUpViewer(); + const res = await request(port, `localhost:${port}`, "/favicon.svg"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("image/svg+xml"); + expect(res.headers["cache-control"]).toBe("public, max-age=3600"); + // SVG payload must actually be SVG, not the proxied REST error body. + expect(res.body).toMatch(/^<svg\b/); + expect(res.body).toContain("</svg>"); + // Sanity-check the artwork: rounded dark tile + green "AM" lettering. + expect(res.body).toContain('fill="#111111"'); + expect(res.body).toContain(">AM<"); + }); +}); diff --git a/test/viewer-session-id.test.ts b/test/viewer-session-id.test.ts new file mode 100644 index 0000000..a671df8 --- /dev/null +++ b/test/viewer-session-id.test.ts @@ -0,0 +1,242 @@ +import * as vm from "node:vm"; +import { describe, expect, it } from "vitest"; +import { renderViewerDocument } from "../src/viewer/document.js"; + +function htmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +function loadViewerSandbox() { + const rendered = renderViewerDocument(); + expect(rendered.found).toBe(true); + if (!rendered.found) throw new Error("viewer document not found"); + + const scriptMatch = rendered.html.match(/<script nonce="[^"]+">([\s\S]*?)<\/script>/); + expect(scriptMatch).not.toBeNull(); + if (!scriptMatch) throw new Error("viewer script not found"); + + const elements = new Map<string, any>(); + const createMockElement = (id = "") => { + const attributes = new Map<string, string>(); + const classes = new Set<string>(); + const listeners = new Map<string, Array<(event?: unknown) => void>>(); + return { + id, + innerHTML: "", + textContent: "", + value: "", + checked: false, + dataset: {}, + style: {}, + listeners, + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + contains: (name: string) => classes.has(name), + toggle: (name: string, force?: boolean) => { + const enabled = force ?? !classes.has(name); + if (enabled) classes.add(name); + else classes.delete(name); + return enabled; + }, + }, + addEventListener: (type: string, handler: (event?: unknown) => void) => { + const current = listeners.get(type) || []; + current.push(handler); + listeners.set(type, current); + }, + getAttribute: (name: string) => attributes.get(name) ?? null, + setAttribute: (name: string, value: unknown) => { + attributes.set(name, String(value)); + }, + // Added in #313 — switchTab toggles aria-selected via removeAttribute + // on the non-active tab buttons. The mock previously only had + // get/setAttribute, so the new hash-routing path threw TypeError. + removeAttribute: (name: string) => { + attributes.delete(name); + }, + querySelectorAll: () => [], + }; + }; + const getElement = (id: string) => { + if (!elements.has(id)) elements.set(id, createMockElement(id)); + return elements.get(id); + }; + + const tabs = [ + "dashboard", + "graph", + "memories", + "timeline", + "sessions", + "lessons", + "actions", + "crystals", + "audit", + "activity", + "profile", + "replay", + ]; + const tabButtons = tabs.map((tab) => ({ ...createMockElement(), dataset: { tab } })); + const views = tabs.map((tab) => ({ ...createMockElement(`view-${tab}`), id: `view-${tab}` })); + const checkboxes = [createMockElement(), createMockElement()].map((el) => ({ ...el, checked: false })); + const querySelectorAll = (selector: string) => { + if (selector === ".tab-bar button") return tabButtons; + if (selector === ".view") return views; + if (selector === 'input[type="checkbox"]') return checkboxes; + return []; + }; + + const document = { + documentElement: { dataset: {} }, + createElement: () => { + let text = ""; + return { + set textContent(value: unknown) { + text = String(value ?? ""); + }, + get innerHTML() { + return htmlEscape(text); + }, + }; + }, + getElementById: getElement, + querySelectorAll, + addEventListener: () => {}, + }; + + const sandbox: Record<string, any> = { + console: { log: () => {}, warn: () => {}, error: () => {} }, + document, + window: { + location: { + search: "", + port: "3113", + protocol: "http:", + hostname: "localhost", + host: "localhost:3113", + origin: "http://localhost:3113", + }, + matchMedia: () => ({ matches: false }), + addEventListener: () => {}, + }, + // Stubbed in #313 — the viewer now calls history.replaceState + // inside updateTabRoute → switchTab to drive the hash-route surface. + // The vm sandbox is otherwise zero-globals so the call would + // throw ReferenceError. No-op is fine for the rendering tests. + history: { replaceState: () => {}, pushState: () => {} }, + location: { + hash: "", + pathname: "/", + search: "", + }, + localStorage: { getItem: () => null, setItem: () => {} }, + sessionStorage: (() => { + const values = new Map<string, string>(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }; + })(), + fetch: async () => ({ ok: true, json: async () => ({}) }), + WebSocket: function WebSocket() {}, + navigator: { userAgent: "vitest" }, + Element: function Element() {}, + alert: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + setTimeout: () => 0, + clearTimeout: () => {}, + URLSearchParams, + Date, + Math, + Promise, + JSON, + Array, + Object, + String, + Number, + parseInt, + encodeURIComponent, + }; + + const scriptWithoutAutoStart = scriptMatch[1].replace( + /\n\s*loadTab\('dashboard'\);\n\s*connectWs\(\);\n\s*startDashboardAutoRefresh\(\);\s*$/, + "\n", + ); + + vm.createContext(sandbox); + vm.runInContext(scriptWithoutAutoStart, sandbox); + + return { sandbox, getElement }; +} + +describe("viewer session rendering", () => { + it("attaches the saved viewer bearer to API calls", async () => { + const { sandbox } = loadViewerSandbox(); + const requests: Array<{ url: string; opts: { headers?: Record<string, string> } }> = []; + sandbox.sessionStorage.setItem("agentmemory-viewer-token", "viewer-secret"); + sandbox.fetch = async (url: string, opts: { headers?: Record<string, string> }) => { + requests.push({ url, opts }); + return { ok: true, json: async () => ({ ok: true }) }; + }; + + await sandbox.apiGet("health"); + + expect(requests).toHaveLength(1); + expect(requests[0].opts.headers?.Authorization).toBe("Bearer viewer-secret"); + }); + + it("shows where to find AGENTMEMORY_SECRET after a viewer auth failure", async () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.fetch = async () => ({ ok: false, status: 401, json: async () => ({}) }); + + await sandbox.apiGet("health"); + + const prompt = getElement("viewer-auth"); + expect(prompt.classList.contains("open")).toBe(true); + expect(prompt.innerHTML).toContain("AGENTMEMORY_SECRET"); + expect(prompt.innerHTML).toContain("unlock viewer API access"); + expect(prompt.innerHTML).not.toContain("fly logs"); + expect(prompt.innerHTML).not.toContain("/data/.hmac"); + }); + + it("does not throw when dashboard sessions are missing ids", () => { + const { sandbox, getElement } = loadViewerSandbox(); + sandbox.state.dashboard = { + loaded: true, + health: { status: "healthy", health: {} }, + sessions: [{ status: "active", observationCount: 3, startedAt: "2026-05-13T12:00:00Z" }], + memories: [], + graphStats: null, + recentAudit: [], + lessons: [], + crystals: [], + }; + + expect(() => sandbox.renderDashboard()).not.toThrow(); + expect(getElement("view-dashboard").innerHTML).toContain("Unknown session"); + }); + + it("does not throw when timeline and sessions tabs receive sessions missing ids", () => { + const { sandbox, getElement } = loadViewerSandbox(); + const sessions = [{ status: "active", observationCount: 1, startedAt: "2026-05-13T12:00:00Z" }]; + + expect(() => sandbox.renderTimelineToolbar(sessions)).not.toThrow(); + expect(getElement("view-timeline").innerHTML).toContain("Unknown session"); + + sandbox.state.sessions.items = sessions; + expect(() => sandbox.renderSessions()).not.toThrow(); + expect(getElement("view-sessions").innerHTML).toContain("Unknown session"); + + const tabButtons = sandbox.document.querySelectorAll(".tab-bar button"); + expect(tabButtons.length).toBeGreaterThan(0); + expect(() => sandbox.switchTab("sessions")).not.toThrow(); + expect(tabButtons.some((button: any) => button.classList.contains("active"))).toBe(true); + }); +}); diff --git a/test/vision-search.test.ts b/test/vision-search.test.ts new file mode 100644 index 0000000..015b3e2 --- /dev/null +++ b/test/vision-search.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { registerVisionSearchFunctions } from "../src/functions/vision-search.js"; +import type { EmbeddingProvider } from "../src/types.js"; +import { KV } from "../src/state/schema.js"; + +const IMAGES_DIR = join(homedir(), ".agentmemory", "images"); +const LOGIN_REF = join(IMAGES_DIR, "login.png"); +const DASH_REF = join(IMAGES_DIR, "dashboard.png"); +const OTHER_REF = join(IMAGES_DIR, "other.png"); + +function mockKV() { + const store = new Map<string, Map<string, unknown>>(); + return { + get: async <T>(scope: string, key: string): Promise<T | null> => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async <T>(scope: string, key: string, data: T): Promise<T> => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise<void> => { + store.get(scope)?.delete(key); + }, + list: async <T>(scope: string): Promise<T[]> => { + if (!store.has(scope)) return []; + return Array.from(store.get(scope)!.values()) as T[]; + }, + }; +} + +function unit(v: number[]): Float32Array { + let norm = 0; + for (const x of v) norm += x * x; + norm = Math.sqrt(norm); + return new Float32Array(v.map((x) => x / (norm || 1))); +} + +describe("vision-search", () => { + let visionSearch: (data: Record<string, unknown>) => Promise<Record<string, unknown>>; + let visionEmbed: (data: Record<string, unknown>) => Promise<Record<string, unknown>>; + let kv: ReturnType<typeof mockKV>; + + const fakeProvider: EmbeddingProvider = { + name: "fake-clip", + dimensions: 3, + embed: async (text: string) => { + if (text.includes("login")) return unit([1, 0, 0]); + if (text.includes("dashboard")) return unit([0, 1, 0]); + return unit([0, 0, 1]); + }, + embedBatch: async (texts: string[]) => Promise.all(texts.map((t) => fakeProvider.embed(t))), + embedImage: async (ref: string) => { + if (ref.endsWith("login.png")) return unit([1, 0.1, 0]); + if (ref.endsWith("dashboard.png")) return unit([0, 1, 0.1]); + return unit([0, 0.1, 1]); + }, + }; + + async function seedRef(ref: string): Promise<void> { + await kv.set(KV.imageRefs, ref, 1); + } + + beforeEach(() => { + kv = mockKV(); + const handlers: Record<string, (data: Record<string, unknown>) => Promise<Record<string, unknown>>> = {}; + const sdk = { + registerFunction: vi.fn((id: string, cb) => { + handlers[id] = cb; + }), + } as unknown as import("iii-sdk").ISdk; + registerVisionSearchFunctions(sdk, kv as never, fakeProvider); + visionSearch = handlers["mem::vision-search"]!; + visionEmbed = handlers["mem::vision-embed"]!; + }); + + it("embeds and stores image vectors in KV.imageEmbeddings", async () => { + await seedRef(LOGIN_REF); + const res = (await visionEmbed({ imageRef: LOGIN_REF })) as { success: boolean; dimensions: number }; + expect(res.success).toBe(true); + expect(res.dimensions).toBe(3); + const stored = await kv.list(KV.imageEmbeddings); + expect(stored.length).toBe(1); + }); + + it("rejects imageRef outside the managed image store", async () => { + const res = (await visionEmbed({ imageRef: "/etc/passwd" })) as { success: boolean; error: string }; + expect(res.success).toBe(false); + expect(res.error).toMatch(/managed/); + }); + + it("rejects imageRef not registered in mem:image-refs", async () => { + const res = (await visionEmbed({ imageRef: LOGIN_REF })) as { success: boolean; error: string }; + expect(res.success).toBe(false); + expect(res.error).toMatch(/not registered/); + }); + + it("text query ranks the matching image first", async () => { + for (const r of [LOGIN_REF, DASH_REF, OTHER_REF]) await seedRef(r); + await visionEmbed({ imageRef: LOGIN_REF }); + await visionEmbed({ imageRef: DASH_REF }); + await visionEmbed({ imageRef: OTHER_REF }); + + const res = (await visionSearch({ queryText: "the login form", topK: 3 })) as { + success: boolean; + results: Array<{ imageRef: string; score: number }>; + }; + expect(res.success).toBe(true); + expect(res.results[0].imageRef).toBe(LOGIN_REF); + expect(res.results[0].score).toBeGreaterThan(res.results[1].score); + }); + + it("image-to-image query finds the same image first", async () => { + await seedRef(LOGIN_REF); + await seedRef(DASH_REF); + await visionEmbed({ imageRef: LOGIN_REF }); + await visionEmbed({ imageRef: DASH_REF }); + + const res = (await visionSearch({ queryImageRef: LOGIN_REF, topK: 2 })) as { + success: boolean; + results: Array<{ imageRef: string; score: number }>; + }; + expect(res.results[0].imageRef).toBe(LOGIN_REF); + expect(res.results[0].score).toBeGreaterThan(0.9); + }); + + it("queryImageRef outside managed store is rejected", async () => { + const res = (await visionSearch({ queryImageRef: "/etc/passwd" })) as { success: boolean; error: string }; + expect(res.success).toBe(false); + expect(res.error).toMatch(/managed/); + }); + + it("sessionId filters out embeddings from other sessions", async () => { + await seedRef(LOGIN_REF); + await seedRef(DASH_REF); + await visionEmbed({ imageRef: LOGIN_REF, sessionId: "sess_a" }); + await visionEmbed({ imageRef: DASH_REF, sessionId: "sess_b" }); + + const res = (await visionSearch({ queryText: "anything", sessionId: "sess_a", topK: 5 })) as { + success: boolean; + results: Array<{ sessionId?: string }>; + }; + expect(res.results.every((r) => r.sessionId === "sess_a")).toBe(true); + expect(res.results.length).toBe(1); + }); + + it("clamps NaN/fractional topK to a valid integer", async () => { + await seedRef(LOGIN_REF); + await visionEmbed({ imageRef: LOGIN_REF }); + const resNan = (await visionSearch({ queryText: "x", topK: Number.NaN })) as { + success: boolean; + results: unknown[]; + }; + expect(resNan.success).toBe(true); + expect(resNan.results.length).toBe(1); + const resFrac = (await visionSearch({ queryText: "x", topK: 3.7 })) as { success: boolean; results: unknown[] }; + expect(resFrac.success).toBe(true); + expect(resFrac.results.length).toBe(1); + }); + + it("returns 503-equivalent error when provider is absent", async () => { + const handlers: Record<string, (data: Record<string, unknown>) => Promise<Record<string, unknown>>> = {}; + const sdk = { + registerFunction: vi.fn((id: string, cb) => { + handlers[id] = cb; + }), + } as unknown as import("iii-sdk").ISdk; + registerVisionSearchFunctions(sdk, kv as never, null); + const res = (await handlers["mem::vision-search"]!({ queryText: "login" })) as { + success: boolean; + error: string; + }; + expect(res.success).toBe(false); + expect(res.error).toMatch(/disabled/); + }); + + it("rejects missing query", async () => { + const res = (await visionSearch({})) as { success: boolean; error: string }; + expect(res.success).toBe(false); + expect(res.error).toMatch(/required/); + }); +}); diff --git a/test/working-memory.test.ts b/test/working-memory.test.ts new file mode 100644 index 0000000..446438d --- /dev/null +++ b/test/working-memory.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockKv = { + get: vi.fn(), + set: vi.fn(), + list: vi.fn(), + delete: vi.fn(), +}; + +const mockSdk = { + registerFunction: vi.fn(), +}; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/functions/audit.js", () => ({ + recordAudit: vi.fn(), +})); + +import { registerWorkingMemoryFunctions } from "../src/functions/working-memory.js"; + +describe("working-memory", () => { + let handlers: Record<string, Function>; + + beforeEach(() => { + vi.clearAllMocks(); + mockKv.list.mockResolvedValue([]); + mockKv.get.mockResolvedValue(null); + mockKv.set.mockResolvedValue(undefined); + mockKv.delete.mockResolvedValue(undefined); + + handlers = {}; + mockSdk.registerFunction.mockImplementation((idOrMeta: any, handler: any) => { + const id = typeof idOrMeta === "string" ? idOrMeta : idOrMeta.id; + handlers[id] = handler; + }); + + registerWorkingMemoryFunctions(mockSdk as any, mockKv as any, 4000); + }); + + it("registers all working memory functions", () => { + expect(handlers["mem::core-add"]).toBeDefined(); + expect(handlers["mem::core-remove"]).toBeDefined(); + expect(handlers["mem::core-list"]).toBeDefined(); + expect(handlers["mem::working-context"]).toBeDefined(); + expect(handlers["mem::auto-page"]).toBeDefined(); + }); + + it("core-add creates an entry", async () => { + const result = await handlers["mem::core-add"]({ + content: "Always use feature branches", + importance: 8, + }); + expect(result.success).toBe(true); + expect(result.id).toMatch(/^core_/); + expect(mockKv.set).toHaveBeenCalledOnce(); + }); + + it("core-add rejects empty content", async () => { + const result = await handlers["mem::core-add"]({ content: "" }); + expect(result.success).toBe(false); + }); + + it("core-add clamps importance", async () => { + await handlers["mem::core-add"]({ content: "test", importance: 99 }); + const saved = mockKv.set.mock.calls[0][2]; + expect(saved.importance).toBe(10); + }); + + it("core-remove deletes entry", async () => { + const result = await handlers["mem::core-remove"]({ id: "core_123" }); + expect(result.success).toBe(true); + expect(mockKv.delete).toHaveBeenCalledOnce(); + }); + + it("core-list returns sorted entries", async () => { + mockKv.list.mockResolvedValue([ + { id: "a", content: "low", importance: 3 }, + { id: "b", content: "high", importance: 9 }, + ]); + const result = await handlers["mem::core-list"](); + expect(result.entries[0].importance).toBe(9); + }); + + it("working-context builds core + archival sections", async () => { + mockKv.list.mockImplementation((scope: string) => { + if (scope === "mem:core-memory") { + return [ + { + id: "c1", + content: "Use iii primitives", + importance: 9, + pinned: true, + accessCount: 5, + lastAccessedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + }, + ]; + } + if (scope === "mem:memories") { + return [ + { + id: "m1", + type: "pattern", + title: "API pattern", + content: "REST endpoints follow /api/resource convention", + isLatest: true, + strength: 0.8, + updatedAt: new Date().toISOString(), + }, + ]; + } + return []; + }); + + const result = await handlers["mem::working-context"]({ + sessionId: "s1", + project: "test", + }); + expect(result.success).toBe(true); + expect(result.coreEntries).toBe(1); + expect(result.context).toContain("Core Memory"); + expect(result.context).toContain("Use iii primitives"); + }); + + it("auto-page removes lowest-value unpinned entries", async () => { + const entries = Array.from({ length: 20 }, (_, i) => ({ + id: `c${i}`, + content: "x".repeat(300), + importance: i, + pinned: i === 19, + accessCount: i, + lastAccessedAt: new Date(Date.now() - i * 86400000).toISOString(), + createdAt: new Date().toISOString(), + })); + mockKv.list.mockResolvedValue(entries); + + const result = await handlers["mem::auto-page"]({ budget: 4000 }); + expect(result.success).toBe(true); + expect(result.paged).toBeGreaterThan(0); + }); +}); diff --git a/test/xml.test.ts b/test/xml.test.ts new file mode 100644 index 0000000..a080c9e --- /dev/null +++ b/test/xml.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { getXmlTag, getXmlChildren } from '../src/prompts/xml.js' + +describe('getXmlTag', () => { + it('extracts simple tag content', () => { + expect(getXmlTag('<title>Hello World', 'title')).toBe('Hello World') + }) + + it('extracts multiline content', () => { + expect(getXmlTag('\nLine 1\nLine 2\n', 'narrative')).toBe('Line 1\nLine 2') + }) + + it('returns empty string for missing tag', () => { + expect(getXmlTag('Hello', 'missing')).toBe('') + }) + + it('returns first match for duplicate tags', () => { + expect(getXmlTag('FirstSecond', 'title')).toBe('First') + }) + + it('returns empty string for empty tag', () => { + expect(getXmlTag('', 'title')).toBe('') + }) + + it('trims whitespace', () => { + expect(getXmlTag(' trimmed ', 'title')).toBe('trimmed') + }) + + it('returns empty for invalid tag names', () => { + expect(getXmlTag('bar', '.*')).toBe('') + }) + + it('returns empty for tag with special regex chars', () => { + expect(getXmlTag('bar', 'a(b')).toBe('') + }) +}) + +describe('getXmlChildren', () => { + it('extracts child elements', () => { + const xml = 'OneTwo' + expect(getXmlChildren(xml, 'facts', 'fact')).toEqual(['One', 'Two']) + }) + + it('returns empty array for missing parent', () => { + expect(getXmlChildren('bar', 'facts', 'fact')).toEqual([]) + }) + + it('returns empty array for missing children', () => { + expect(getXmlChildren('', 'facts', 'fact')).toEqual([]) + }) + + it('trims child content', () => { + const xml = ' trimmed ' + expect(getXmlChildren(xml, 'facts', 'fact')).toEqual(['trimmed']) + }) + + it('handles multiline children', () => { + const xml = 'Use JWT\nfor auth' + expect(getXmlChildren(xml, 'decisions', 'decision')).toEqual(['Use JWT\nfor auth']) + }) + + it('returns empty for invalid parent tag name', () => { + expect(getXmlChildren('A', '.*', 'fact')).toEqual([]) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a5d7567 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "src/hooks"] +} diff --git a/tsdown.config.ts b/tsdown.config.ts new file mode 100644 index 0000000..5cf1084 --- /dev/null +++ b/tsdown.config.ts @@ -0,0 +1,87 @@ +import { defineConfig } from "tsdown"; + +const hookEntries = [ + "src/hooks/session-start.ts", + "src/hooks/prompt-submit.ts", + "src/hooks/pre-tool-use.ts", + "src/hooks/post-tool-use.ts", + "src/hooks/post-tool-failure.ts", + "src/hooks/pre-compact.ts", + "src/hooks/subagent-start.ts", + "src/hooks/subagent-stop.ts", + "src/hooks/notification.ts", + "src/hooks/task-completed.ts", + "src/hooks/stop.ts", + "src/hooks/session-end.ts", + "src/hooks/post-commit.ts", +]; + +const shared = { + format: ["esm"] as const, + target: "node20" as const, + // Keep these as node_modules imports (deps.neverBundle). We never import + // onnxruntime-{node,web} directly; they come in transitively through + // @xenova/transformers, which is lazy-loaded from + // src/providers/embedding/{clip,local}.ts and src/state/reranker.ts. + // Bundling inlines relative paths like + // `../bin/napi-v3/darwin/arm64/onnxruntime_binding.node` that no longer + // resolve from dist/. All are declared as optionalDependencies in + // package.json so users can install them only when they enable local + // embeddings / CLIP / reranker. + deps: { + neverBundle: [ + "@xenova/transformers", + "onnxruntime-node", + "onnxruntime-web", + "@anthropic-ai/claude-agent-sdk", + "@anthropic-ai/sdk", + ], + }, + // Each entry is its own build, so the per-entry dts/deps timing notice + // fires ~30 times and drowns the real output. It is informational only. + inputOptions: { + checks: { pluginTimings: false }, + }, +}; + +export default defineConfig([ + { + entry: ["src/index.ts"], + outDir: "dist", + ...shared, + dts: true, + clean: true, + sourcemap: true, + banner: { js: "#!/usr/bin/env node" }, + }, + { + entry: ["src/cli.ts"], + outDir: "dist", + ...shared, + clean: false, + sourcemap: false, + }, + { + entry: ["src/mcp/standalone.ts"], + outDir: "dist", + ...shared, + clean: false, + sourcemap: false, + }, + // One entry per config block prevents tsdown from hoisting shared + // helpers into hashed chunks across hooks. + ...hookEntries.map((entry) => ({ + entry: [entry], + outDir: "dist/hooks", + ...shared, + clean: false, + sourcemap: false, + })), + ...hookEntries.map((entry) => ({ + entry: [entry], + outDir: "plugin/scripts", + ...shared, + clean: false, + sourcemap: false, + })), +]); diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000..b0c0807 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,10 @@ +node_modules +.next +out +.env*.local +.vercel +*.tsbuildinfo +.DS_Store +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..09d40e8 --- /dev/null +++ b/website/README.md @@ -0,0 +1,63 @@ +# agentmemory website + +Next.js 15 App Router landing page for agentmemory. Lamborghini-inspired +black + gold design system. Deploys to Vercel with zero config. + +## Stack + +- Next.js 15.1 (App Router, React 19, TypeScript 5.7) +- `next/font` for Archivo + JetBrains Mono +- CSS Modules + one `globals.css` +- No Tailwind, no bundler config, no client-side routing + +## Local dev + +```bash +cd website +npm install +npm run dev +# open http://localhost:3000 +``` + +## Deploy (Vercel) + +Two options: + +1. Import the repo on vercel.com and set **Root Directory** to `website/`. That's it. +2. Or `npx vercel` from the `website/` directory. + +No env vars required. Node 20 LTS or newer. + +## Structure + +``` +website/ + app/ + layout.tsx — + fonts + metadata + viewport + page.tsx — composes the landing sections in order + globals.css — design tokens, buttons, section-head utilities + components/ + Nav.tsx — hexagonal bull mark + menu + Hero.tsx — title + lede + CTAs + MemoryGraph.tsx — client canvas animation + hexagonal pause + scroll rail + Stats.tsx — counter-up on intersect + Primitives.tsx — three cards with 3D mouse tilt + LiveTerminal.tsx — typewriter replay of memory.recall + consolidate + Compare.tsx — agentmemory vs Mem0/Letta/Cognee table + Agents.tsx — supported-agents grid + Install.tsx — click-to-copy npm + console commands + Footer.tsx — source / changelog / license links + ScrollProgress.tsx — thin gold progress bar at the top of the viewport + next.config.ts + tsconfig.json + package.json +``` + +Each interactive component is a `"use client"` island. Everything else +renders on the server. + +## Design source + +`/DESIGN.md` at the repo root (generated by +`npx getdesign@latest add lamborghini`). Colors, type, spacing rules live +there. Every new component should reference it first. diff --git a/website/app/globals.css b/website/app/globals.css new file mode 100644 index 0000000..9996093 --- /dev/null +++ b/website/app/globals.css @@ -0,0 +1,160 @@ +:root { + --abyss: #000000; + --iron: #181818; + --charcoal: #202020; + --ash: #7d7d7d; + --steel: #969696; + --mist: #e6e6e6; + --white: #ffffff; + --gold: #ffc000; + --gold-deep: #917300; + --gold-soft: #ffce3e; + --cyan: #29abe2; + --blue: #3860be; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--abyss); + color: var(--white); + font-family: var(--font-archivo), "Helvetica Neue", Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + scroll-behavior: smooth; +} + +a { + color: inherit; + text-decoration: none; + transition: color 160ms ease; +} +a:hover { + color: var(--blue); +} + +button { + font-family: inherit; + cursor: pointer; + background: none; + border: none; + color: inherit; +} + +::selection { + background: var(--gold); + color: var(--abyss); +} + +/* Reveal animation */ +.reveal { + opacity: 0; + transform: translateY(32px); + transition: opacity 600ms ease, + transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1); +} +.reveal.is-visible { + opacity: 1; + transform: translateY(0); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} + +/* Buttons shared */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 18px 28px; + font-size: 14.4px; + font-weight: 700; + letter-spacing: 0.2px; + text-transform: uppercase; + border: 1px solid transparent; + border-radius: 0; + transition: + background 200ms ease, + color 200ms ease, + border-color 200ms ease, + opacity 200ms ease; + cursor: pointer; +} +.btn--accent { + background: var(--gold); + color: var(--abyss); + padding: 22px 32px; + font-size: 16px; +} +.btn--accent:hover { + background: var(--gold-deep); + color: var(--white); +} +.btn--ghost { + background: transparent; + color: var(--white); + border-color: rgba(255, 255, 255, 0.5); + opacity: 0.85; +} +.btn--ghost:hover { + background: rgba(255, 255, 255, 0.08); + border-color: var(--white); + color: var(--white); + opacity: 1; +} +.btn--small { + padding: 10px 14px; + font-size: 12px; +} + +/* Shared section head */ +.section-head { + max-width: 1200px; + margin: 0 auto 56px; + padding: 0 40px; +} +.section-eyebrow { + display: inline-block; + font-size: 12px; + letter-spacing: 0.96px; + color: var(--gold); + text-transform: uppercase; + margin-bottom: 16px; + padding: 6px 10px; + border: 1px solid var(--gold); +} +.section-title { + font-size: clamp(32px, 5vw, 72px); + font-weight: 900; + line-height: 0.98; + letter-spacing: -0.01em; + margin: 0 0 24px; + text-transform: uppercase; + text-wrap: balance; +} +.section-lede { + max-width: 680px; + font-size: 16px; + letter-spacing: 0.12px; + color: var(--steel); + text-transform: uppercase; + margin: 0; +} + +@media (max-width: 640px) { + .section-head { + padding: 0 20px; + } +} diff --git a/website/app/layout.tsx b/website/app/layout.tsx new file mode 100644 index 0000000..95cb92f --- /dev/null +++ b/website/app/layout.tsx @@ -0,0 +1,59 @@ +import type { Metadata, Viewport } from "next"; +import { Archivo, JetBrains_Mono } from "next/font/google"; +import "./globals.css"; + +const archivo = Archivo({ + subsets: ["latin"], + weight: ["400", "500", "700", "900"], + variable: "--font-archivo", + display: "swap", +}); + +const jetbrains = JetBrains_Mono({ + subsets: ["latin"], + weight: ["400", "500"], + variable: "--font-mono", + display: "swap", +}); + +export const metadata: Metadata = { + metadataBase: new URL("https://agentmemory.dev"), + title: "AGENTMEMORY — PERSISTENT MEMORY FOR AI CODING AGENTS", + description: + "The memory layer your coding agent should have had from day one. 95.2% retrieval R@5. 92% fewer tokens. 0 external databases. Works with every agent.", + icons: { + icon: [{ url: "/icon.svg", type: "image/svg+xml" }], + apple: "/icon.svg", + }, + openGraph: { + title: "agentmemory", + description: + "Persistent memory for AI coding agents. Runs locally. Zero external databases.", + type: "website", + url: "/", + }, + twitter: { + card: "summary_large_image", + title: "agentmemory", + description: + "Persistent memory for AI coding agents. Runs locally. Zero external databases.", + }, +}; + +export const viewport: Viewport = { + themeColor: "#000000", + width: "device-width", + initialScale: 1, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/website/app/opengraph-image.tsx b/website/app/opengraph-image.tsx new file mode 100644 index 0000000..bff489c --- /dev/null +++ b/website/app/opengraph-image.tsx @@ -0,0 +1,107 @@ +import { ImageResponse } from "next/og"; + +export const alt = "agentmemory — persistent memory for AI coding agents"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default function Image() { + return new ImageResponse( + ( +
+
+
+ AGENTMEMORY +
+ +
+
+ AGENT + MEMORY +
+
+ Persistent memory for AI coding agents. Runs locally. Zero external + databases. +
+
+ +
+
+ 95.2% + RETRIEVAL R@5 +
+
+ 92% + FEWER TOKENS +
+
+ 0 + EXTERNAL DBs +
+
+
+ ), + { ...size }, + ); +} diff --git a/website/app/page.tsx b/website/app/page.tsx new file mode 100644 index 0000000..afb4a40 --- /dev/null +++ b/website/app/page.tsx @@ -0,0 +1,45 @@ +import { ScrollProgress } from "@/components/ScrollProgress"; +import { Nav } from "@/components/Nav"; +import { Hero } from "@/components/Hero"; +import { Stats } from "@/components/Stats"; +import { Primitives } from "@/components/Primitives"; +import { Features } from "@/components/Features"; +import { CommandCenter } from "@/components/CommandCenter"; +import { LiveTerminal } from "@/components/LiveTerminal"; +import { Compare } from "@/components/Compare"; +import { Testimonials } from "@/components/Testimonials"; +import { Agents } from "@/components/Agents"; +import { Install } from "@/components/Install"; +import { Footer } from "@/components/Footer"; +import { getProjectMeta } from "@/lib/meta"; + +export default function Page() { + const meta = getProjectMeta(); + return ( + <> + +