Yash030 commited on
Commit
8ef8de0
·
1 Parent(s): 51884ad

Clean up legacy TS/Node files, fix Dolt backup path sync bug, and update CSP connect-src header

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. benchmark/COMPARISON.md +0 -158
  2. benchmark/LONGMEMEVAL.md +0 -79
  3. benchmark/QUALITY.md +0 -75
  4. benchmark/README.md +0 -100
  5. benchmark/REAL-EMBEDDINGS.md +0 -67
  6. benchmark/SCALE.md +0 -110
  7. benchmark/dataset.ts +0 -293
  8. benchmark/lib/percentiles.ts +0 -22
  9. benchmark/load-100k.ts +0 -528
  10. benchmark/longmemeval-bench.ts +0 -317
  11. benchmark/quality-eval.ts +0 -643
  12. benchmark/real-embeddings-eval.ts +0 -405
  13. benchmark/results/load-100k-96c0ed0.json +0 -61
  14. benchmark/scale-eval.ts +0 -398
  15. eval/README.md +0 -111
  16. eval/data/coding-agent-life-v1/queries.json +0 -107
  17. eval/data/coding-agent-life-v1/sessions.json +0 -77
  18. eval/runner/adapters/agentmemory.ts +0 -93
  19. eval/runner/adapters/grep.ts +0 -36
  20. eval/runner/adapters/vector.ts +0 -108
  21. eval/runner/coding-life.ts +0 -101
  22. eval/runner/load.ts +0 -54
  23. eval/runner/longmemeval.ts +0 -126
  24. eval/runner/score.ts +0 -78
  25. eval/runner/types.ts +0 -38
  26. eval/scripts/sandbox.sh +0 -117
  27. integrations/filesystem-watcher/README.md +0 -63
  28. integrations/filesystem-watcher/bin.mjs +0 -28
  29. integrations/filesystem-watcher/package.json +0 -22
  30. integrations/filesystem-watcher/watcher.mjs +0 -317
  31. integrations/openclaw/README.md +0 -155
  32. integrations/openclaw/openclaw.plugin.json +0 -27
  33. integrations/openclaw/package.json +0 -10
  34. integrations/openclaw/plugin.mjs +0 -217
  35. integrations/openclaw/plugin.yaml +0 -27
  36. integrations/pi/README.md +0 -77
  37. integrations/pi/index.ts +0 -275
  38. integrations/pi/package.json +0 -5
  39. integrations/pi/security.ts +0 -35
  40. plugin/.claude-plugin/plugin.json +0 -13
  41. plugin/.codex-plugin/plugin.json +0 -15
  42. plugin/.mcp.copilot.json +0 -15
  43. plugin/.mcp.json +0 -13
  44. plugin/hooks/hooks.codex.json +0 -67
  45. plugin/hooks/hooks.copilot.json +0 -72
  46. plugin/hooks/hooks.json +0 -125
  47. plugin/opencode/README.md +0 -229
  48. plugin/opencode/agentmemory-capture.ts +0 -687
  49. plugin/opencode/commands/recall.md +0 -19
  50. plugin/opencode/commands/remember.md +0 -19
benchmark/COMPARISON.md DELETED
@@ -1,158 +0,0 @@
1
- # AI Agent Memory: Benchmark Comparison
2
-
3
- How agentmemory compares against other persistent memory solutions for AI coding agents.
4
-
5
- All numbers here come from published benchmarks or public repositories. We link to primary sources wherever possible so you can reproduce.
6
-
7
- ---
8
-
9
- ## Retrieval Accuracy (LongMemEval)
10
-
11
- [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).
12
-
13
- | System | Benchmark | R@5 | Notes |
14
- |---|---|---|---|
15
- | **agentmemory** (BM25 + Vector) | LongMemEval-S | **95.2%** | `all-MiniLM-L6-v2` embeddings, no API key |
16
- | agentmemory (BM25-only) | LongMemEval-S | 86.2% | Fallback when no embedding provider available |
17
- | 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) |
18
- | Letta / MemGPT | LoCoMo | 83.2% | Different benchmark (LoCoMo, not LongMemEval) |
19
- | Mem0 | LoCoMo | 68.5% | Different benchmark (LoCoMo, not LongMemEval) |
20
-
21
- **⚠️ 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 reports LongMemEval-S, 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.
22
-
23
- Full agentmemory methodology: [`LONGMEMEVAL.md`](LONGMEMEVAL.md)
24
-
25
- ---
26
-
27
- ## Feature Matrix
28
-
29
- | Feature | agentmemory | mem0 | Letta/MemGPT | Khoj | supermemory | MemPalace | Hippo |
30
- |---|---|---|---|---|---|---|---|
31
- | **GitHub stars** | Growing | 58K+ | 23K+ | 35K+ | 26K+ | 54K+ | Trending |
32
- | **Type** | Memory engine + MCP server | Memory layer API | Full agent runtime | Personal AI | Memory API + app | Benchmark-focused OSS | Memory system |
33
- | **Auto-capture via hooks** | ✅ 12 lifecycle hooks | ❌ Manual `add()` | ❌ Agent self-edits | ❌ Manual | ❌ API-side extraction | ❌ Manual | ❌ Manual |
34
- | **Search strategy** | BM25 + Vector + Graph | Vector + Graph | Vector (archival) | Semantic | Vector + RAG | Vector-only (large model) | Decay-weighted |
35
- | **Multi-agent coordination** | ✅ Leases + signals + mesh | ❌ | Runtime-internal only | ❌ | ❌ | ❌ | Multi-agent shared |
36
- | **Framework lock-in** | None | None | High | Standalone | None (drop-in wrappers) | None | None |
37
- | **External deps** | None | Qdrant/pgvector | Postgres + vector | Multiple | Managed cloud | Vector store | None |
38
- | **Self-hostable** | ✅ default | Optional | Optional | ✅ | ❌ Cloud-only | ✅ | ✅ |
39
- | **Knowledge graph** | ✅ Entity extraction + BFS | ✅ Mem0g variant | ❌ | Doc links | ❌ | ❌ | ❌ |
40
- | **Memory decay** | ✅ Ebbinghaus + tiered | ❌ | ❌ | ❌ | ✅ Auto-forget | ❌ | ✅ Half-lives |
41
- | **4-tier consolidation** | ✅ Working → episodic → semantic → procedural | ❌ | OS-inspired tiers | ❌ | ❌ | ❌ | Episodic + semantic |
42
- | **Version / supersession** | ✅ Jaccard-based | Passive | ❌ | ❌ | ✅ Auto-resolve | ❌ | ❌ |
43
- | **Real-time viewer** | ✅ Port 3113 | Cloud dashboard | Cloud dashboard | Web UI | Cloud dashboard | ❌ | ❌ |
44
- | **Privacy filtering** | ✅ Strips secrets pre-store | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
45
- | **Obsidian export** | ✅ Built-in | ❌ | ❌ | Native format | ❌ | ❌ | ❌ |
46
- | **Cross-agent** | ✅ MCP + REST | API calls | Within runtime | Standalone | MCP + API | Standalone | Multi-agent shared |
47
- | **Audit trail** | ✅ All mutations logged | ❌ | Limited | ❌ | ❌ | ❌ | ❌ |
48
- | **Language SDKs** | Any (REST + MCP) | Python + TS | Python only | API | Python + TS | Python | Node |
49
-
50
- ---
51
-
52
- ## Token Efficiency
53
-
54
- The main reason to use persistent memory at all: token cost. Here's what one year of heavy agent use looks like across approaches.
55
-
56
- | Approach | Tokens / year | Cost / year | Notes |
57
- |---|---|---|---|
58
- | Paste full history into context | 19.5M+ | Impossible | Exceeds context window after ~200 observations |
59
- | LLM-summarized memory (extraction-based) | ~650K | ~$500 | Lossy — summarization drops detail |
60
- | **agentmemory (API embeddings)** | **~170K** | **~$10** | Token-budgeted, only relevant memories injected |
61
- | **agentmemory (local embeddings)** | **~170K** | **$0** | `all-MiniLM-L6-v2` runs in-process |
62
- | supermemory | Not published | Cloud pricing | Managed API, no local token budget |
63
- | Mem0 | Varies by integration | Varies | Extraction-based, no token budget |
64
-
65
- **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.
66
-
67
- ---
68
-
69
- ## What Each Tool Is Best At
70
-
71
- This isn't a "agentmemory wins everything" page. Different tools solve different problems.
72
-
73
- **Choose agentmemory if you want:**
74
- - Automatic capture with zero manual `add()` calls
75
- - MCP server that works across Claude Code, Cursor, Codex, Gemini CLI, etc.
76
- - Hybrid BM25 + vector + graph search
77
- - Real-time viewer to see what your agent is learning
78
- - Self-hostable with zero external databases
79
- - Privacy filtering on API keys and secrets
80
- - Multi-agent coordination (leases, signals, routines)
81
-
82
- **Choose Mem0 if you want:**
83
- - Framework-agnostic API to bolt onto an existing agent
84
- - Managed cloud option with a dashboard
85
- - Python + TypeScript SDKs for direct integration
86
- - Entity/relationship extraction as the primary abstraction
87
-
88
- **Choose Letta/MemGPT if you want:**
89
- - A full agent runtime, not just memory
90
- - OS-inspired memory tiers (core/archival/recall)
91
- - Agents that self-edit their memory via function calls
92
- - Long-running conversational agents (weeks/months)
93
-
94
- **Choose Khoj if you want:**
95
- - A personal AI second brain, not agent infrastructure
96
- - Document-first search over your files and the web
97
- - Obsidian/Notion/Emacs integrations
98
- - Scheduled automations and research tasks
99
-
100
- **Choose supermemory if you want:**
101
- - A managed memory API with server-side auto-extraction and automatic forgetting
102
- - Drop-in wrappers for major AI frameworks (Vercel AI, LangChain, LangGraph)
103
- - A hosted dashboard with no infrastructure to run yourself
104
- - RAG plus memory served from a single query
105
-
106
- **Choose MemPalace if you want:**
107
- - A simple, free, open-source vector memory store
108
- - To chase its self-reported retrieval benchmark (we have not reproduced it)
109
- - Pure retrieval over agent workflow features
110
- - Note: no auto-capture, no MCP, no multi-agent coordination, so you wire all integration yourself
111
-
112
- **Choose Hippo if you want:**
113
- - Biologically-inspired memory model (decay, consolidation, sleep)
114
- - Multi-agent shared memory as a primary feature
115
- - "Forget by default, earn persistence through use" philosophy
116
-
117
- ---
118
-
119
- ## Running Your Own Benchmarks
120
-
121
- We encourage you to measure this yourself rather than trust any README. Here's how:
122
-
123
- ```bash
124
- # Clone the repo
125
- git clone https://github.com/rohitg00/agentmemory.git
126
- cd agentmemory && npm install
127
-
128
- # Run LongMemEval-S
129
- npm run bench:longmemeval
130
-
131
- # Run quality benchmark (240 observations, 20 queries)
132
- npm run bench:quality
133
-
134
- # Run scale benchmark
135
- npm run bench:scale
136
-
137
- # Run real embeddings benchmark
138
- npm run bench:real-embeddings
139
- ```
140
-
141
- Results land in `benchmark/results/`. All scripts, datasets, and results are committed for reproducibility.
142
-
143
- ---
144
-
145
- ## Corrections Welcome
146
-
147
- 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.
148
-
149
- If you want to add your tool to this comparison, open a PR with:
150
- 1. A link to your benchmark methodology
151
- 2. The metric and dataset you're measuring on
152
- 3. A commit hash / version so we can reproduce
153
-
154
- **Sources:**
155
- - Mem0 LoCoMo benchmark: [mem0.ai blog](https://mem0.ai)
156
- - Letta LoCoMo benchmark: [letta.com/blog/benchmarking-ai-agent-memory](https://letta.com/blog/benchmarking-ai-agent-memory)
157
- - LongMemEval paper: [arxiv.org/abs/2410.10813](https://arxiv.org/abs/2410.10813)
158
- - LoCoMo paper: [snap-stanford.github.io/LoCoMo](https://snap-stanford.github.io/LoCoMo/)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/LONGMEMEVAL.md DELETED
@@ -1,79 +0,0 @@
1
- # LongMemEval-S Benchmark Results
2
-
3
- [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.
4
-
5
- ## Setup
6
-
7
- - **Dataset**: LongMemEval-S (500 questions, ~48 sessions per question, ~115K tokens)
8
- - **Source**: [xiaowu0162/longmemeval-cleaned](https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned)
9
- - **Metric**: `recall_any@K` — does ANY gold session appear in top-K retrieved results?
10
- - **Embedding model**: `all-MiniLM-L6-v2` (384 dimensions, local, no API key)
11
- - **No LLM in the loop**: Pure retrieval evaluation, no answer generation or judge
12
-
13
- ## Results
14
-
15
- | System | R@5 | R@10 | R@20 | NDCG@10 | MRR |
16
- |---|---|---|---|---|---|
17
- | **agentmemory BM25+Vector** | **95.2%** | **98.6%** | **99.4%** | **87.9%** | **88.2%** |
18
- | agentmemory BM25-only | 86.2% | 94.6% | 98.6% | 73.0% | 71.5% |
19
- | MemPalace raw (vector-only) | 96.6% | ~97.6% | — | — | — |
20
-
21
- ### By Question Type (BM25+Vector)
22
-
23
- | Type | R@5 | R@10 | Count |
24
- |---|---|---|---|
25
- | knowledge-update | 98.7% | 100.0% | 78 |
26
- | multi-session | 97.7% | 100.0% | 133 |
27
- | single-session-assistant | 96.4% | 98.2% | 56 |
28
- | temporal-reasoning | 95.5% | 97.7% | 133 |
29
- | single-session-user | 90.0% | 97.1% | 70 |
30
- | single-session-preference | 83.3% | 96.7% | 30 |
31
-
32
- ### By Question Type (BM25-only)
33
-
34
- | Type | R@5 | R@10 | Count |
35
- |---|---|---|---|
36
- | knowledge-update | 92.3% | 98.7% | 78 |
37
- | single-session-user | 91.4% | 95.7% | 70 |
38
- | temporal-reasoning | 88.0% | 94.7% | 133 |
39
- | multi-session | 86.5% | 96.2% | 133 |
40
- | single-session-assistant | 80.4% | 91.1% | 56 |
41
- | single-session-preference | 60.0% | 80.0% | 30 |
42
-
43
- ## Analysis
44
-
45
- 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).
46
-
47
- 2. **BM25 alone gets 86.2%** — keyword search with Porter stemming and synonym expansion is surprisingly effective on conversational data.
48
-
49
- 3. **Adding vectors to BM25 gives +9pp** (86.2% → 95.2%), the largest improvement from any single component.
50
-
51
- 4. **Preferences are the hardest category** for both BM25 (60%) and hybrid (83.3%). These require understanding implicit/indirect statements.
52
-
53
- 5. **Multi-session and knowledge-update are strongest** (97.7%+ hybrid). The hybrid approach excels when facts are distributed across sessions.
54
-
55
- 6. **R@10 reaches 98.6%** — nearly all gold sessions are found within the top 10 results.
56
-
57
- ## Important Notes on Methodology
58
-
59
- - These are **retrieval recall** scores, not end-to-end QA accuracy. The official LongMemEval metric is QA accuracy (retrieve + generate answer + GPT-4o judge).
60
- - Systems on the actual LongMemEval QA leaderboard score 60-95% depending on the LLM reader (Oracle GPT-4o gets ~82.4%).
61
- - We do NOT claim these as "LongMemEval scores" — they are retrieval-only evaluations on the LongMemEval-S haystack.
62
- - Each question builds a fresh index from its ~48 sessions, searches with the question text, and checks if gold session IDs appear in results.
63
-
64
- ## Reproducibility
65
-
66
- ```bash
67
- # Download dataset (264 MB)
68
- pip install huggingface_hub
69
- python3 -c "
70
- from huggingface_hub import hf_hub_download
71
- hf_hub_download(repo_id='xiaowu0162/longmemeval-cleaned', filename='longmemeval_s_cleaned.json', repo_type='dataset', local_dir='benchmark/data')
72
- "
73
-
74
- # Run BM25-only
75
- npx tsx benchmark/longmemeval-bench.ts bm25
76
-
77
- # Run BM25+Vector hybrid (requires @xenova/transformers)
78
- npx tsx benchmark/longmemeval-bench.ts hybrid
79
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/QUALITY.md DELETED
@@ -1,75 +0,0 @@
1
- # agentmemory v0.6.0 — Search Quality Evaluation (Internal Dataset)
2
-
3
- > 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**.
4
-
5
- **Date:** 2026-03-18T07:44:43.397Z
6
- **Dataset:** 240 synthetic observations across 30 sessions (internal coding project)
7
- **Queries:** 20 labeled queries with ground-truth relevance
8
- **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)
9
-
10
- ## Head-to-Head Comparison
11
-
12
- | System | Recall@5 | Recall@10 | Precision@5 | NDCG@10 | MRR | Latency | Tokens/query |
13
- |--------|----------|-----------|-------------|---------|-----|---------|--------------|
14
- | Built-in (CLAUDE.md / grep) | 37.0% | 55.8% | 78.0% | 80.3% | 82.5% | 0.50ms | 22,610 |
15
- | Built-in (200-line MEMORY.md) | 27.4% | 37.8% | 63.0% | 56.4% | 65.5% | 0.16ms | 7,938 |
16
- | BM25-only | 43.8% | 55.9% | 95.0% | 82.7% | 95.5% | 0.17ms | 3,142 |
17
- | Dual-stream (BM25+Vector) | 42.4% | 58.6% | 90.0% | 84.7% | 95.4% | 0.71ms | 3,142 |
18
- | Triple-stream (BM25+Vector+Graph) | 36.8% | 58.0% | 87.0% | 81.7% | 87.9% | 1.02ms | 3,142 |
19
-
20
- ## Why This Matters
21
-
22
- **Recall improvement:** agentmemory triple-stream finds 58.0% of relevant memories at K=10 vs 55.8% for keyword grep (+4%)
23
- **Token savings:** agentmemory returns only the top 10 results (3,142 tokens) vs loading everything into context (22,610 tokens) — 86% reduction
24
- **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.
25
-
26
- ## Per-Query Breakdown (Triple-Stream)
27
-
28
- | Query | Category | Recall@10 | NDCG@10 | MRR | Relevant | Latency |
29
- |-------|----------|-----------|---------|-----|----------|---------|
30
- | How did we set up authentication? | semantic | 50.0% | 100.0% | 100.0% | 20 | 1.7ms |
31
- | JWT token validation middleware | exact | 50.0% | 64.9% | 100.0% | 10 | 1.2ms |
32
- | PostgreSQL connection issues | semantic | 33.3% | 100.0% | 100.0% | 30 | 1.0ms |
33
- | Playwright test configuration | exact | 100.0% | 100.0% | 100.0% | 10 | 1.1ms |
34
- | Why did the production deployment fail? | cross-session | 33.3% | 100.0% | 100.0% | 30 | 0.8ms |
35
- | rate limiting implementation | exact | 80.0% | 64.1% | 33.3% | 10 | 0.7ms |
36
- | What security measures did we add? | semantic | 33.3% | 100.0% | 100.0% | 30 | 0.7ms |
37
- | database performance optimization | semantic | 0.0% | 0.0% | 7.1% | 25 | 0.8ms |
38
- | Kubernetes pod crash debugging | entity | 100.0% | 96.7% | 100.0% | 5 | 1.2ms |
39
- | Docker containerization setup | entity | 100.0% | 100.0% | 100.0% | 10 | 0.9ms |
40
- | How does caching work in the app? | semantic | 25.0% | 64.9% | 100.0% | 20 | 0.8ms |
41
- | test infrastructure and factories | exact | 50.0% | 64.9% | 100.0% | 10 | 0.7ms |
42
- | What happened with the OAuth callback error? | cross-session | 100.0% | 54.1% | 16.7% | 5 | 1.1ms |
43
- | monitoring and observability setup | semantic | 66.7% | 100.0% | 100.0% | 15 | 0.8ms |
44
- | Prisma ORM configuration | entity | 25.7% | 93.6% | 100.0% | 35 | 1.8ms |
45
- | CI/CD pipeline configuration | exact | 20.0% | 64.9% | 100.0% | 25 | 1.0ms |
46
- | memory leak debugging | cross-session | 100.0% | 100.0% | 100.0% | 5 | 0.7ms |
47
- | API design decisions | semantic | 25.0% | 64.9% | 100.0% | 20 | 1.4ms |
48
- | zod validation schemas | entity | 66.7% | 100.0% | 100.0% | 15 | 0.7ms |
49
- | infrastructure as code Terraform | entity | 100.0% | 100.0% | 100.0% | 5 | 1.5ms |
50
-
51
- ## By Query Category
52
-
53
- | Category | Avg Recall@10 | Avg NDCG@10 | Avg MRR | Queries |
54
- |----------|---------------|-------------|---------|---------|
55
- | exact | 60.0% | 71.8% | 86.7% | 5 |
56
- | semantic | 33.3% | 75.7% | 86.7% | 7 |
57
- | cross-session | 77.8% | 84.7% | 72.2% | 3 |
58
- | entity | 78.5% | 98.1% | 100.0% | 5 |
59
-
60
- ## Context Window Analysis
61
-
62
- The fundamental problem with built-in agent memory:
63
-
64
- | Observations | MEMORY.md tokens | agentmemory tokens (top 10) | Savings | MEMORY.md reachable |
65
- |-------------|-----------------|---------------------------|---------|-------------------|
66
- | 240 | 12,000 | 3,142 | 74% | 83% |
67
- | 500 | 25,000 | 3,142 | 87% | 40% |
68
- | 1,000 | 50,000 | 3,142 | 94% | 20% |
69
- | 5,000 | 250,000 | 3,142 | 99% | 4% |
70
-
71
- 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.
72
-
73
- ---
74
-
75
- *100 evaluations across 5 systems. Ground-truth labels assigned by concept matching against observation metadata.*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/README.md DELETED
@@ -1,100 +0,0 @@
1
- # benchmark/
2
-
3
- Two kinds of numbers live in this directory:
4
-
5
- 1. **Quality / retrieval** — `longmemeval-bench.ts`, `quality-eval.ts`,
6
- `real-embeddings-eval.ts`, `scale-eval.ts`. Recall, precision, token
7
- savings. Documented in `LONGMEMEVAL.md`, `QUALITY.md`,
8
- `REAL-EMBEDDINGS.md`, `SCALE.md`.
9
-
10
- 2. **Load shape** — `load-100k.ts`. p50 / p90 / p99 latency and
11
- throughput against a running daemon. This is the file you want when
12
- somebody asks "what's p99 at 100k memories under concurrency 100?".
13
-
14
- ## load-100k.ts
15
-
16
- Hand-rolled, dependency-free load harness. Issues real HTTP against a
17
- local agentmemory daemon at `http://localhost:3111`, records per-request
18
- latency with `performance.now()`, and writes a JSON report per run.
19
-
20
- ### What it measures
21
-
22
- For each cell in the matrix `(N, concurrency, endpoint)` it records:
23
-
24
- - `p50_ms`, `p90_ms`, `p99_ms` — nearest-rank percentiles.
25
- - `min_ms`, `max_ms`, `ops`, `errors`.
26
- - `throughput_per_sec` — wall-clock ops / sec for that cell.
27
-
28
- Default matrix:
29
-
30
- - `N` ∈ {1000, 10000, 100000} — number of memories seeded before the
31
- cell runs.
32
- - `C` ∈ {1, 10, 100} — concurrent in-flight requests during the cell.
33
- - Endpoints under test:
34
- - `POST /agentmemory/remember`
35
- - `POST /agentmemory/smart-search`
36
- - `GET /agentmemory/memories?latest=true`
37
-
38
- Each cell issues `BENCH_OPS=200` requests by default — enough samples
39
- for stable p99 without dragging a 100k-seed run past tens of minutes.
40
-
41
- ### Why p99 is the number that matters
42
-
43
- p50 tells you the median request feels fast. p90 tells you the bulk of
44
- requests feel fast. **p99 tells you the request your tail user hits when
45
- they really need it feels fast.** Capacity planning lives here — if you
46
- want to size a fleet, scale your daemon, or set an SLO, p99 is the
47
- number to plan against. p50 will lie to you.
48
-
49
- ### Running it
50
-
51
- ```bash
52
- # 1. Start the daemon however you normally do (npx, Docker, etc.)
53
- npx @agentmemory/agentmemory
54
-
55
- # 2. From the repo root, in another shell:
56
- npm run bench:load
57
- ```
58
-
59
- To override the matrix:
60
-
61
- ```bash
62
- BENCH_N=1000 BENCH_C=1,10 BENCH_OPS=100 npm run bench:load
63
- ```
64
-
65
- To have the harness spawn a daemon for the run (after `npm run build`):
66
-
67
- ```bash
68
- AGENTMEMORY_BENCH_AUTOSTART=1 npm run bench:load
69
- ```
70
-
71
- Other env knobs (see the file header for the canonical list):
72
-
73
- - `AGENTMEMORY_URL` — base URL of the daemon (default
74
- `http://localhost:3111`).
75
- - `BENCH_SEED` — seed for the `mulberry32` content RNG. Same seed +
76
- same daemon build = byte-identical seed corpus.
77
- - `BENCH_OUT_DIR` — where the JSON report lands (default
78
- `benchmark/results/`).
79
-
80
- ### Where results land
81
-
82
- `benchmark/results/load-100k-<short-git-sha>.json`. The harness
83
- `mkdir -p`s the directory. The file has a `schema_version: 1` field so
84
- future format changes don't silently break consumers.
85
-
86
- ### Content generation is seedable
87
-
88
- Synthetic memory content is built from a small noun / verb / concept
89
- vocabulary fed by a `mulberry32(BENCH_SEED)` PRNG. Same seed + same
90
- build = same corpus. The point isn't "realistic" content (there isn't
91
- one realistic content); the point is **reproducibility** — re-running
92
- the harness against the same git sha should give the same content
93
- mixture going in, so latency variance comes from the daemon and not
94
- from JSON payload jitter.
95
-
96
- ### Publishing numbers per release
97
-
98
- The release flow appends a `## Performance` section to `CHANGELOG.md`
99
- referencing the JSON in `benchmark/results/` for that release's git
100
- sha. p99 is the headline number; the JSON is the receipt.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/REAL-EMBEDDINGS.md DELETED
@@ -1,67 +0,0 @@
1
- # agentmemory v0.6.0 — Real Embeddings Quality Evaluation
2
-
3
- **Date:** 2026-03-18T07:38:21.450Z
4
- **Platform:** darwin arm64, Node v20.20.0
5
- **Dataset:** 240 observations, 30 sessions, 20 labeled queries
6
- **Embedding model:** Xenova/all-MiniLM-L6-v2 (384d, local, no API key)
7
-
8
- ## Head-to-Head: Real Embeddings vs Keyword Search
9
-
10
- | System | Recall@5 | Recall@10 | Precision@5 | NDCG@10 | MRR | Avg Latency | Tokens/query |
11
- |--------|----------|-----------|-------------|---------|-----|-------------|--------------|
12
- | Built-in (grep all) | 37.0% | 55.8% | 78.0% | 80.3% | 82.5% | 0.44ms | 19,462 |
13
- | BM25-only (stemmed+synonyms) | 43.8% | 55.9% | 95.0% | 82.7% | 95.5% | 0.26ms | 1,571 |
14
- | Dual-stream (BM25+Xenova) | 43.8% | 64.1% | 98.0% | 94.9% | 100.0% | 2.39ms | 1,571 |
15
- | Triple-stream (BM25+Xenova+Graph) | 43.8% | 64.1% | 98.0% | 94.9% | 100.0% | 2.07ms | 1,571 |
16
-
17
- ## Improvement from Real Embeddings
18
-
19
- Adding real vector embeddings to BM25 improves recall@10 by **8.2 percentage points**.
20
- Token savings vs loading everything: **92%** (1,571 vs 19,462 tokens).
21
-
22
- ## Per-Query: Where Real Embeddings Win
23
-
24
- Queries where dual-stream (real embeddings) outperforms BM25-only:
25
-
26
- | Query | Category | BM25 Recall@10 | +Vector Recall@10 | Delta |
27
- |-------|----------|---------------|-------------------|-------|
28
- | How did we set up authentication? | semantic | 25.0% | 45.0% | +20.0pp ** |
29
- | Playwright test configuration | exact | 50.0% | 90.0% | +40.0pp ** |
30
- | database performance optimization | semantic | 0.0% | 40.0% | +40.0pp ** |
31
- | test infrastructure and factories | exact | 50.0% | 80.0% | +30.0pp ** |
32
- | Prisma ORM configuration | entity | 14.3% | 28.6% | +14.3pp ** |
33
- | CI/CD pipeline configuration | exact | 20.0% | 40.0% | +20.0pp ** |
34
-
35
- ## By Category Comparison
36
-
37
- | Category | Built-in grep | BM25 (stemmed) | +Real Vectors | +Graph |
38
- |----------|--------------|----------------|--------------|--------|
39
- | exact | 48.0% | 54.0% | 72.0% | 72.0% |
40
- | semantic | 35.5% | 33.3% | 41.9% | 41.9% |
41
- | cross-session | 77.8% | 77.8% | 77.8% | 77.8% |
42
- | entity | 79.0% | 76.2% | 79.0% | 79.0% |
43
-
44
- ## Embedding Performance
45
-
46
- | System | Embedding Time | Model | Dimensions |
47
- |--------|---------------|-------|------------|
48
- | Dual-stream (BM25+Xenova) | 3.1s | Xenova/all-MiniLM-L6-v2 | 384 |
49
- | Triple-stream (BM25+Xenova+Graph) | 2.9s | Xenova/all-MiniLM-L6-v2 | 384 |
50
-
51
- Embedding is a one-time cost at ingestion. Search is sub-millisecond after indexing.
52
-
53
- ## Key Findings
54
-
55
- 1. **Semantic queries improve most**: 8.6pp recall@10 gain from real embeddings
56
- 2. **"database performance optimization"** — the hardest query — goes from BM25 0.0% to vector-augmented 40.0%
57
- 3. **Entity/exact queries** are already well-served by BM25+stemming — vectors add marginal value
58
- 4. **Local embeddings (Xenova)** run without API keys — zero cost, zero latency concerns
59
-
60
- ## Recommendation
61
-
62
- Enable local embeddings by default (`EMBEDDING_PROVIDER=local` or install `@xenova/transformers`).
63
- This gives agentmemory genuine semantic search that built-in agent memories cannot match —
64
- understanding that "database performance optimization" relates to "N+1 query fix" and "eager loading".
65
-
66
- ---
67
- *All measurements use Xenova/all-MiniLM-L6-v2 local embeddings (384 dimensions, no API calls).*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/SCALE.md DELETED
@@ -1,110 +0,0 @@
1
- # agentmemory v0.6.0 — Scale & Cross-Session Evaluation
2
-
3
- **Date:** 2026-03-18T07:45:03.529Z
4
- **Platform:** darwin arm64, Node v20.20.0
5
-
6
- ## 1. Scale: agentmemory vs Built-in Memory
7
-
8
- 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.
9
-
10
- | Observations | Sessions | Index Build | BM25 Search | Hybrid Search | Heap | Context Tokens (built-in) | Context Tokens (agentmemory) | Savings | Built-in Unreachable |
11
- |-------------|----------|------------|-------------|---------------|------|--------------------------|-----------------------------|---------|--------------------|
12
- | 240 | 30 | 177ms | 0.112ms | 0.63ms | 9MB | 10,504 | 1,924 | 82% | 17% |
13
- | 1,000 | 125 | 155ms | 0.317ms | 1.709ms | 6MB | 43,834 | 1,969 | 96% | 80% |
14
- | 5,000 | 625 | 810ms | 1.496ms | 8.58ms | 25MB | 220,335 | 1,972 | 99% | 96% |
15
- | 10,000 | 1250 | 1657ms | 3.195ms | 17.49ms | 1MB | 440,973 | 1,974 | 100% | 98% |
16
- | 50,000 | 6250 | 9182ms | 22.827ms | 108.722ms | 316MB | 2,216,173 | 1,981 | 100% | 100% |
17
-
18
- ### What the numbers mean
19
-
20
- **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.
21
-
22
- **Context Tokens (agentmemory):** How many tokens the top-10 search results consume. Stays constant regardless of corpus size.
23
-
24
- **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.
25
-
26
- ### Storage Costs
27
-
28
- | Observations | BM25 Index | Vector Index (d=384) | Total Storage |
29
- |-------------|-----------|---------------------|---------------|
30
- | 240 | 395 KB | 494 KB | 0.9 MB |
31
- | 1,000 | 1,599 KB | 2,060 KB | 3.6 MB |
32
- | 5,000 | 8,006 KB | 10,298 KB | 17.9 MB |
33
- | 10,000 | 16,005 KB | 20,596 KB | 35.7 MB |
34
- | 50,000 | 80,126 KB | 102,979 KB | 178.8 MB |
35
-
36
- ## 2. Cross-Session Retrieval
37
-
38
- Can the system find relevant information from past sessions? This is impossible for built-in memory once observations exceed the line/context cap.
39
-
40
- | Query | Target Session | Gap | BM25 Found | BM25 Rank | Hybrid Found | Hybrid Rank | Built-in Visible |
41
- |-------|---------------|-----|-----------|-----------|-------------|-------------|-----------------|
42
- | How did we set up OAuth providers? | ses_005-009 | 24 | Yes | #1 | Yes | #1 | Yes |
43
- | What was the N+1 query fix? | ses_010-014 | 18 | Yes | #1 | Yes | #2 | Yes |
44
- | PostgreSQL full-text search setup | ses_010-014 | 17 | Yes | #1 | Yes | #1 | Yes |
45
- | bcrypt password hashing configuration | ses_005-009 | 20 | Yes | #1 | Yes | #1 | Yes |
46
- | Vitest unit testing setup | ses_020-024 | 9 | Yes | #1 | Yes | #1 | Yes |
47
- | webhook retry exponential backoff | ses_015-019 | 14 | Yes | #1 | Yes | #1 | Yes |
48
- | ESLint flat config migration | ses_000-004 | 29 | Yes | #1 | Yes | #1 | Yes |
49
- | Kubernetes HPA autoscaling configuration | ses_025-029 | 4 | Yes | #1 | Yes | #1 | No |
50
- | Prisma database seed script | ses_010-014 | 16 | Yes | #1 | Yes | #1 | Yes |
51
- | API cursor-based pagination | ses_015-019 | 14 | Yes | #1 | Yes | #1 | Yes |
52
- | CSRF protection double-submit cookie | ses_005-009 | 24 | Yes | #1 | Yes | #1 | Yes |
53
- | blue-green deployment rollback | ses_025-029 | 4 | Yes | #1 | Yes | #1 | No |
54
-
55
- **Summary:** agentmemory BM25 found 12/12 cross-session queries. Hybrid found 12/12. Built-in memory (200-line cap) could only reach 10/12.
56
-
57
- ## 3. The Context Window Problem
58
-
59
- ```
60
- Agent context window: ~200K tokens
61
- System prompt + tools: ~20K tokens
62
- User conversation: ~30K tokens
63
- Available for memory: ~150K tokens
64
-
65
- At 50 tokens/observation:
66
- 200 observations = 10,000 tokens (fits, but 200-line cap hits first)
67
- 1,000 observations = 50,000 tokens (33% of available budget)
68
- 5,000 observations = 250,000 tokens (EXCEEDS total context window)
69
-
70
- agentmemory top-10 results:
71
- Any corpus size = ~1,924 tokens (0.3% of budget)
72
- ```
73
-
74
- ## 4. What Built-in Memory Cannot Do
75
-
76
- | Capability | Built-in (CLAUDE.md) | agentmemory |
77
- |-----------|---------------------|-------------|
78
- | Semantic search | No (keyword grep only) | BM25 + vector + graph |
79
- | Scale beyond 200 lines | No (hard cap) | Unlimited |
80
- | Cross-session recall | Only if in 200-line window | Full corpus search |
81
- | Cross-agent sharing | No (per-agent files) | MCP + REST API |
82
- | Multi-agent coordination | No | Leases, signals, actions |
83
- | Temporal queries | No | Point-in-time graph |
84
- | Memory lifecycle | No (manual pruning) | Ebbinghaus decay + eviction |
85
- | Knowledge graph | No | Entity extraction + traversal |
86
- | Query expansion | No | LLM-generated reformulations |
87
- | Retention scoring | No | Time-frequency decay model |
88
- | Real-time dashboard | No (read files manually) | Viewer on :3113 |
89
- | Concurrent access | No (file lock) | Keyed mutex + KV store |
90
-
91
- ## 5. When to Use What
92
-
93
- **Use built-in memory (CLAUDE.md) when:**
94
- - You have < 200 items to remember
95
- - Single agent, single project
96
- - Preferences and quick facts only
97
- - Zero setup is the priority
98
-
99
- **Use agentmemory when:**
100
- - Project history exceeds 200 observations
101
- - You need to recall specific incidents from weeks ago
102
- - Multiple agents work on the same codebase
103
- - You want semantic search ("how does auth work?") not just keyword matching
104
- - You need to track memory quality, decay, and lifecycle
105
- - You want a shared memory layer across Claude Code, Cursor, Windsurf, etc.
106
-
107
- Built-in memory is your sticky notes. agentmemory is the searchable database behind them.
108
-
109
- ---
110
- *Scale tests: 5 corpus sizes. Cross-session tests: 12 queries targeting specific past sessions.*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/dataset.ts DELETED
@@ -1,293 +0,0 @@
1
- import type { CompressedObservation } from "../src/types.js";
2
-
3
- export interface LabeledQuery {
4
- query: string;
5
- relevantObsIds: string[];
6
- description: string;
7
- category: "exact" | "semantic" | "temporal" | "cross-session" | "entity";
8
- }
9
-
10
- const SESSION_COUNT = 30;
11
- const OBS_PER_SESSION = 8;
12
-
13
- function ts(daysAgo: number): string {
14
- return new Date(Date.now() - daysAgo * 86400000).toISOString();
15
- }
16
-
17
- const RAW_SESSIONS: Array<{
18
- sessionRange: [number, number];
19
- daysAgoRange: [number, number];
20
- project: string;
21
- observations: Array<Omit<CompressedObservation, "id" | "sessionId" | "timestamp">>;
22
- }> = [
23
- {
24
- sessionRange: [0, 4],
25
- daysAgoRange: [28, 25],
26
- project: "webapp",
27
- observations: [
28
- { 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 },
29
- { 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 },
30
- { 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 },
31
- { 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 },
32
- { 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 },
33
- { 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 },
34
- { 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 },
35
- { 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 },
36
- ],
37
- },
38
- {
39
- sessionRange: [5, 9],
40
- daysAgoRange: [24, 20],
41
- project: "webapp",
42
- observations: [
43
- { 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 },
44
- { 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 },
45
- { 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 },
46
- { 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 },
47
- { 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 },
48
- { 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 },
49
- { 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 },
50
- { 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 },
51
- ],
52
- },
53
- {
54
- sessionRange: [10, 14],
55
- daysAgoRange: [19, 15],
56
- project: "webapp",
57
- observations: [
58
- { 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 },
59
- { 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 },
60
- { 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 },
61
- { 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 },
62
- { 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 },
63
- { 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 },
64
- { 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 },
65
- { 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 },
66
- ],
67
- },
68
- {
69
- sessionRange: [15, 19],
70
- daysAgoRange: [14, 10],
71
- project: "webapp",
72
- observations: [
73
- { 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 },
74
- { 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 },
75
- { 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 },
76
- { 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 },
77
- { 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 },
78
- { 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 },
79
- { 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 },
80
- { 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 },
81
- ],
82
- },
83
- {
84
- sessionRange: [20, 24],
85
- daysAgoRange: [9, 5],
86
- project: "webapp",
87
- observations: [
88
- { 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 },
89
- { 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 },
90
- { 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 },
91
- { 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 },
92
- { 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 },
93
- { 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 },
94
- { 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 },
95
- { 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 },
96
- ],
97
- },
98
- {
99
- sessionRange: [25, 29],
100
- daysAgoRange: [4, 0],
101
- project: "webapp",
102
- observations: [
103
- { 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 },
104
- { 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 },
105
- { 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 },
106
- { 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 },
107
- { 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 },
108
- { 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 },
109
- { 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 },
110
- { 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 },
111
- ],
112
- },
113
- ];
114
-
115
- export function generateDataset(): {
116
- observations: CompressedObservation[];
117
- queries: LabeledQuery[];
118
- sessions: Map<string, string[]>;
119
- } {
120
- const observations: CompressedObservation[] = [];
121
- const sessions = new Map<string, string[]>();
122
-
123
- for (const group of RAW_SESSIONS) {
124
- const [sStart, sEnd] = group.sessionRange;
125
- const [dStart, dEnd] = group.daysAgoRange;
126
-
127
- for (let s = sStart; s <= sEnd; s++) {
128
- const sessionId = `ses_${s.toString().padStart(3, "0")}`;
129
- const daysAgo = dStart - ((s - sStart) / Math.max(1, sEnd - sStart)) * (dStart - dEnd);
130
- const obsIds: string[] = [];
131
-
132
- const obsPerSession = Math.min(group.observations.length, OBS_PER_SESSION);
133
- for (let o = 0; o < obsPerSession; o++) {
134
- const idx = ((s - sStart) * obsPerSession + o) % group.observations.length;
135
- const raw = group.observations[idx];
136
- const obsId = `obs_${sessionId}_${o.toString().padStart(2, "0")}`;
137
- const hourOffset = o * 0.5;
138
-
139
- observations.push({
140
- id: obsId,
141
- sessionId,
142
- timestamp: ts(daysAgo - hourOffset / 24),
143
- ...raw,
144
- });
145
- obsIds.push(obsId);
146
- }
147
- sessions.set(sessionId, obsIds);
148
- }
149
- }
150
-
151
- const queries: LabeledQuery[] = [
152
- {
153
- query: "How did we set up authentication?",
154
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["nextauth", "authentication", "oauth", "jwt", "login", "signup"].includes(c))).map(o => o.id),
155
- description: "Should find all auth-related observations across sessions 5-9",
156
- category: "semantic",
157
- },
158
- {
159
- query: "JWT token validation middleware",
160
- relevantObsIds: observations.filter(o => o.concepts.includes("jwt") || (o.concepts.includes("middleware") && o.concepts.includes("authentication"))).map(o => o.id),
161
- description: "Exact match on JWT middleware setup",
162
- category: "exact",
163
- },
164
- {
165
- query: "PostgreSQL connection issues",
166
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["postgresql", "pgbouncer", "connection-pooling", "database"].includes(c))).map(o => o.id),
167
- description: "Should find database connection and pooling observations",
168
- category: "semantic",
169
- },
170
- {
171
- query: "Playwright test configuration",
172
- relevantObsIds: observations.filter(o => o.concepts.includes("playwright") || (o.concepts.includes("e2e-testing"))).map(o => o.id),
173
- description: "E2E testing setup with Playwright",
174
- category: "exact",
175
- },
176
- {
177
- query: "Why did the production deployment fail?",
178
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["debugging", "production", "crashloopbackoff", "timeout", "migration-drift"].includes(c))).map(o => o.id),
179
- description: "Cross-session: find all production debugging incidents",
180
- category: "cross-session",
181
- },
182
- {
183
- query: "rate limiting implementation",
184
- relevantObsIds: observations.filter(o => o.concepts.includes("rate-limiting")).map(o => o.id),
185
- description: "Rate limiting across auth and API modules",
186
- category: "exact",
187
- },
188
- {
189
- query: "What security measures did we add?",
190
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["security", "csrf", "bcrypt", "rate-limiting", "rbac", "password-hashing"].includes(c))).map(o => o.id),
191
- description: "Broad semantic: all security-related work",
192
- category: "semantic",
193
- },
194
- {
195
- query: "database performance optimization",
196
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["n+1", "query-optimization", "database-index", "performance", "eager-loading", "caching"].includes(c))).map(o => o.id),
197
- description: "Performance optimizations across database and caching",
198
- category: "semantic",
199
- },
200
- {
201
- query: "Kubernetes pod crash debugging",
202
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["crashloopbackoff", "kubernetes"].includes(c)) && o.concepts.includes("debugging")).map(o => o.id),
203
- description: "Specific K8s debugging incident",
204
- category: "entity",
205
- },
206
- {
207
- query: "Docker containerization setup",
208
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["docker", "multi-stage-build", "containerization", "dockerfile"].includes(c))).map(o => o.id),
209
- description: "Docker-related observations",
210
- category: "entity",
211
- },
212
- {
213
- query: "How does caching work in the app?",
214
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["redis", "caching", "cache-aside", "ioredis", "elasticache"].includes(c))).map(o => o.id),
215
- description: "All caching-related observations",
216
- category: "semantic",
217
- },
218
- {
219
- query: "test infrastructure and factories",
220
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["test-factories", "testing-infrastructure", "fixtures", "mocking"].includes(c))).map(o => o.id),
221
- description: "Test setup infrastructure",
222
- category: "exact",
223
- },
224
- {
225
- query: "What happened with the OAuth callback error?",
226
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["oauth-debugging", "callback-url"].includes(c))).map(o => o.id),
227
- description: "Specific debugging incident recall",
228
- category: "cross-session",
229
- },
230
- {
231
- query: "monitoring and observability setup",
232
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["datadog", "prometheus", "grafana", "monitoring", "observability", "alerting", "metrics", "logging", "pino"].includes(c))).map(o => o.id),
233
- description: "All monitoring/observability observations",
234
- category: "semantic",
235
- },
236
- {
237
- query: "Prisma ORM configuration",
238
- relevantObsIds: observations.filter(o => o.concepts.includes("prisma")).map(o => o.id),
239
- description: "All Prisma-related observations",
240
- category: "entity",
241
- },
242
- {
243
- query: "CI/CD pipeline configuration",
244
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["ci-cd", "github-actions", "deployment", "ci"].includes(c))).map(o => o.id),
245
- description: "CI/CD related observations",
246
- category: "exact",
247
- },
248
- {
249
- query: "memory leak debugging",
250
- relevantObsIds: observations.filter(o => o.concepts.includes("memory-leak")).map(o => o.id),
251
- description: "Memory leak incidents (WebSocket handler, test suite)",
252
- category: "cross-session",
253
- },
254
- {
255
- query: "API design decisions",
256
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["rest-api", "api-design", "api-versioning", "pagination", "openapi", "error-handling"].includes(c))).map(o => o.id),
257
- description: "API design and architecture decisions",
258
- category: "semantic",
259
- },
260
- {
261
- query: "zod validation schemas",
262
- relevantObsIds: observations.filter(o => o.concepts.includes("zod")).map(o => o.id),
263
- description: "Where zod is used for validation",
264
- category: "entity",
265
- },
266
- {
267
- query: "infrastructure as code Terraform",
268
- relevantObsIds: observations.filter(o => o.concepts.some(c => ["terraform", "infrastructure-as-code", "aws", "vpc", "rds", "elasticache"].includes(c))).map(o => o.id),
269
- description: "Terraform/IaC observations",
270
- category: "entity",
271
- },
272
- ];
273
-
274
- return { observations, queries, sessions };
275
- }
276
-
277
- export function generateScaleDataset(count: number): CompressedObservation[] {
278
- const base = generateDataset().observations;
279
- const result: CompressedObservation[] = [];
280
-
281
- for (let i = 0; i < count; i++) {
282
- const src = base[i % base.length];
283
- result.push({
284
- ...src,
285
- id: `obs_scale_${i.toString().padStart(6, "0")}`,
286
- sessionId: `ses_${Math.floor(i / 8).toString().padStart(4, "0")}`,
287
- timestamp: ts(Math.random() * 90),
288
- title: `${src.title} (iteration ${i})`,
289
- narrative: `${src.narrative} [Scale test variant ${i}, session group ${Math.floor(i / 8)}]`,
290
- });
291
- }
292
- return result;
293
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/lib/percentiles.ts DELETED
@@ -1,22 +0,0 @@
1
- /**
2
- * Nearest-rank percentile over a pre-sorted ascending array of numbers.
3
- *
4
- * No dependencies, no allocation. The caller is responsible for sorting
5
- * the input ascending (`arr.sort((a, b) => a - b)`) — sorting in here
6
- * would hide an O(n log n) cost in what looks like a cheap lookup.
7
- *
8
- * @param sorted Ascending-sorted samples. Empty array returns `NaN`.
9
- * @param p Percentile in [0, 100]. Values outside the range are clamped.
10
- * @returns The sample at the nearest rank, or `NaN` for empty input.
11
- */
12
- export function pXX(sorted: number[], p: number): number {
13
- const n = sorted.length;
14
- if (n === 0) return NaN;
15
- const clamped = Math.max(0, Math.min(100, p));
16
- if (clamped === 0) return sorted[0]!;
17
- if (clamped === 100) return sorted[n - 1]!;
18
- // Nearest-rank: rank = ceil(p/100 * n), index = rank - 1.
19
- const rank = Math.ceil((clamped / 100) * n);
20
- const idx = Math.min(n - 1, Math.max(0, rank - 1));
21
- return sorted[idx]!;
22
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/load-100k.ts DELETED
@@ -1,528 +0,0 @@
1
- /**
2
- * Load harness — seeds N synthetic memories against a local agentmemory
3
- * daemon, then drives a matrix of (N, concurrency, endpoint) cells and
4
- * records p50 / p90 / p99 latency + throughput per cell.
5
- *
6
- * Spec: GitHub issue #346.
7
- *
8
- * Runs against an already-running daemon at `http://localhost:3111` by
9
- * default. Set `AGENTMEMORY_BENCH_AUTOSTART=1` to spawn one via
10
- * `node dist/cli.js start` for the duration of the run.
11
- *
12
- * Env knobs:
13
- * AGENTMEMORY_BENCH_AUTOSTART "1" to spawn the daemon (default: assume up)
14
- * AGENTMEMORY_URL base URL of the daemon (default: http://localhost:3111)
15
- * BENCH_N comma-separated N sizes (default: 1000,10000,100000)
16
- * BENCH_C comma-separated concurrency levels (default: 1,10,100)
17
- * BENCH_OPS ops per cell during measurement (default: 200)
18
- * BENCH_SEED seed for the mulberry32 RNG (default: 0xC0FFEE)
19
- * BENCH_OUT_DIR results dir (default: benchmark/results)
20
- *
21
- * The harness writes one JSON file per run named
22
- * `load-100k-<short-git-sha>.json`. The git sha is best-effort — falls
23
- * back to a timestamp when run outside a checkout.
24
- */
25
-
26
- import { spawn, type ChildProcess } from "node:child_process";
27
- import { execFileSync } from "node:child_process";
28
- import { mkdirSync, writeFileSync, existsSync } from "node:fs";
29
- import { join, resolve } from "node:path";
30
- import { performance } from "node:perf_hooks";
31
-
32
- import { pXX } from "./lib/percentiles.js";
33
-
34
- /** Seedable PRNG. Mulberry32 — 32-bit state, uniform output in [0, 1). */
35
- function mulberry32(seed: number): () => number {
36
- let s = seed >>> 0;
37
- return () => {
38
- s = (s + 0x6d2b79f5) >>> 0;
39
- let t = s;
40
- t = Math.imul(t ^ (t >>> 15), t | 1);
41
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
42
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
43
- };
44
- }
45
-
46
- const NOUNS = [
47
- "cache", "queue", "router", "stream", "shard", "lock", "buffer", "worker",
48
- "engine", "trigger", "function", "memory", "index", "graph", "vector",
49
- "session", "observation", "summary", "embedding", "tokenizer", "scheduler",
50
- "consumer", "producer", "channel", "actor", "pipeline", "watcher", "pool",
51
- ];
52
- const VERBS = [
53
- "flushes", "rotates", "compacts", "rebalances", "drains", "warms",
54
- "expires", "deduplicates", "snapshots", "replays", "promotes", "demotes",
55
- "merges", "splits", "indexes", "scans", "compresses", "uploads",
56
- ];
57
- const CONCEPTS = [
58
- "throughput", "latency", "backpressure", "consistency", "isolation",
59
- "durability", "idempotency", "fan-out", "cardinality", "skew",
60
- "hot-path", "cold-start", "tail-latency", "saturation", "quiescence",
61
- ];
62
-
63
- function buildContent(rng: () => number, i: number): string {
64
- const n = NOUNS[Math.floor(rng() * NOUNS.length)]!;
65
- const v = VERBS[Math.floor(rng() * VERBS.length)]!;
66
- const c1 = CONCEPTS[Math.floor(rng() * CONCEPTS.length)]!;
67
- const c2 = CONCEPTS[Math.floor(rng() * CONCEPTS.length)]!;
68
- const k = Math.floor(rng() * 9999);
69
- return `seed-${i} the ${n} ${v} ${c1} under ${c2} pressure (k=${k})`;
70
- }
71
-
72
- interface RunConfig {
73
- baseUrl: string;
74
- Ns: number[];
75
- Cs: number[];
76
- opsPerCell: number;
77
- seed: number;
78
- outDir: string;
79
- autoStart: boolean;
80
- }
81
-
82
- function parseIntList(raw: string | undefined, fallback: number[]): number[] {
83
- if (!raw) return fallback;
84
- const out = raw
85
- .split(",")
86
- .map((s) => parseInt(s.trim(), 10))
87
- .filter((n) => Number.isFinite(n) && n > 0);
88
- return out.length > 0 ? out : fallback;
89
- }
90
-
91
- function loadConfig(): RunConfig {
92
- return {
93
- baseUrl: (process.env["AGENTMEMORY_URL"] || "http://localhost:3111").replace(
94
- /\/+$/,
95
- "",
96
- ),
97
- Ns: parseIntList(process.env["BENCH_N"], [1000, 10000, 100000]),
98
- Cs: parseIntList(process.env["BENCH_C"], [1, 10, 100]),
99
- opsPerCell: parseInt(process.env["BENCH_OPS"] || "200", 10) || 200,
100
- seed: parseInt(process.env["BENCH_SEED"] || "12648430", 10) || 12648430,
101
- outDir:
102
- process.env["BENCH_OUT_DIR"] ||
103
- resolve(process.cwd(), "benchmark", "results"),
104
- autoStart: process.env["AGENTMEMORY_BENCH_AUTOSTART"] === "1",
105
- };
106
- }
107
-
108
- async function waitForLivez(baseUrl: string, timeoutMs: number): Promise<void> {
109
- const start = Date.now();
110
- let lastErr: unknown = null;
111
- while (Date.now() - start < timeoutMs) {
112
- try {
113
- const res = await fetch(`${baseUrl}/agentmemory/livez`, {
114
- signal: AbortSignal.timeout(2000),
115
- });
116
- if (res.ok) return;
117
- lastErr = new Error(`livez HTTP ${res.status}`);
118
- } catch (err) {
119
- lastErr = err;
120
- }
121
- await new Promise((r) => setTimeout(r, 500));
122
- }
123
- const reason =
124
- lastErr instanceof Error ? lastErr.message : String(lastErr ?? "unknown");
125
- throw new Error(
126
- `daemon at ${baseUrl} did not become ready within ${timeoutMs} ms: ${reason}`,
127
- );
128
- }
129
-
130
- function maybeStartDaemon(): ChildProcess | null {
131
- const candidates = ["dist/cli.mjs", "dist/cli.js"].map((p) =>
132
- resolve(process.cwd(), p),
133
- );
134
- const cliPath = candidates.find((p) => existsSync(p));
135
- if (!cliPath) {
136
- throw new Error(
137
- `AGENTMEMORY_BENCH_AUTOSTART=1 but neither ${candidates.join(" nor ")} exists — run \`npm run build\` first`,
138
- );
139
- }
140
- const child = spawn(process.execPath, [cliPath, "start"], {
141
- stdio: ["ignore", "pipe", "pipe"],
142
- detached: false,
143
- });
144
- child.stdout?.on("data", () => {
145
- /* drain */
146
- });
147
- child.stderr?.on("data", () => {
148
- /* drain */
149
- });
150
- return child;
151
- }
152
-
153
- function shortGitSha(): string {
154
- try {
155
- const sha = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
156
- encoding: "utf8",
157
- stdio: ["ignore", "pipe", "ignore"],
158
- }).trim();
159
- if (sha) return sha;
160
- } catch {
161
- /* no git */
162
- }
163
- return `nogit-${Date.now().toString(36)}`;
164
- }
165
-
166
- interface CellResult {
167
- endpoint: string;
168
- N: number;
169
- C: number;
170
- ops: number;
171
- errors: number;
172
- wall_ms: number;
173
- throughput_per_sec: number;
174
- p50_ms: number;
175
- p90_ms: number;
176
- p99_ms: number;
177
- min_ms: number;
178
- max_ms: number;
179
- }
180
-
181
- /**
182
- * Issue `total` requests against `fetcher`, capped at `concurrency`
183
- * in-flight at any moment. Records per-request latency in ms.
184
- */
185
- async function driveLoad(
186
- concurrency: number,
187
- total: number,
188
- fetcher: (i: number) => Promise<void>,
189
- ): Promise<{ latencies: number[]; errors: number; wallMs: number }> {
190
- const latencies: number[] = [];
191
- let errors = 0;
192
- let issued = 0;
193
- const wallStart = performance.now();
194
-
195
- async function worker(): Promise<void> {
196
- while (true) {
197
- const i = issued++;
198
- if (i >= total) return;
199
- const t0 = performance.now();
200
- try {
201
- await fetcher(i);
202
- latencies.push(performance.now() - t0);
203
- } catch {
204
- errors++;
205
- }
206
- }
207
- }
208
-
209
- const workers = Array.from({ length: Math.max(1, concurrency) }, () =>
210
- worker(),
211
- );
212
- await Promise.allSettled(workers);
213
- const wallMs = performance.now() - wallStart;
214
- return { latencies, errors, wallMs };
215
- }
216
-
217
- function summarize(
218
- endpoint: string,
219
- N: number,
220
- C: number,
221
- latencies: number[],
222
- errors: number,
223
- wallMs: number,
224
- ): CellResult {
225
- const sorted = latencies.slice().sort((a, b) => a - b);
226
- const ops = sorted.length;
227
- return {
228
- endpoint,
229
- N,
230
- C,
231
- ops,
232
- errors,
233
- wall_ms: Math.round(wallMs * 1000) / 1000,
234
- throughput_per_sec:
235
- wallMs > 0 ? Math.round((ops / (wallMs / 1000)) * 100) / 100 : 0,
236
- p50_ms: Math.round(pXX(sorted, 50) * 1000) / 1000,
237
- p90_ms: Math.round(pXX(sorted, 90) * 1000) / 1000,
238
- p99_ms: Math.round(pXX(sorted, 99) * 1000) / 1000,
239
- min_ms: ops > 0 ? Math.round(sorted[0]! * 1000) / 1000 : NaN,
240
- max_ms: ops > 0 ? Math.round(sorted[ops - 1]! * 1000) / 1000 : NaN,
241
- };
242
- }
243
-
244
- async function seedMemories(
245
- baseUrl: string,
246
- count: number,
247
- rng: () => number,
248
- seedConcurrency = 32,
249
- ): Promise<{ seeded: number; errors: number; wallMs: number }> {
250
- let issued = 0;
251
- let seeded = 0;
252
- let errors = 0;
253
- const t0 = performance.now();
254
- async function worker(): Promise<void> {
255
- while (true) {
256
- const i = issued++;
257
- if (i >= count) return;
258
- const body = JSON.stringify({
259
- content: buildContent(rng, i),
260
- type: "observation",
261
- });
262
- try {
263
- const res = await fetch(`${baseUrl}/agentmemory/remember`, {
264
- method: "POST",
265
- headers: { "content-type": "application/json" },
266
- body,
267
- signal: AbortSignal.timeout(30_000),
268
- });
269
- if (res.ok) {
270
- seeded++;
271
- } else {
272
- errors++;
273
- }
274
- // drain body to free the socket
275
- await res.text().catch(() => "");
276
- } catch {
277
- errors++;
278
- }
279
- }
280
- }
281
- await Promise.allSettled(
282
- Array.from({ length: seedConcurrency }, () => worker()),
283
- );
284
- return { seeded, errors, wallMs: performance.now() - t0 };
285
- }
286
-
287
- async function measureRemember(
288
- baseUrl: string,
289
- rng: () => number,
290
- N: number,
291
- C: number,
292
- ops: number,
293
- ): Promise<CellResult> {
294
- const { latencies, errors, wallMs } = await driveLoad(C, ops, async (i) => {
295
- const body = JSON.stringify({
296
- content: buildContent(rng, N + i),
297
- type: "observation",
298
- });
299
- const res = await fetch(`${baseUrl}/agentmemory/remember`, {
300
- method: "POST",
301
- headers: { "content-type": "application/json" },
302
- body,
303
- signal: AbortSignal.timeout(30_000),
304
- });
305
- await res.text().catch(() => "");
306
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
307
- });
308
- return summarize("POST /agentmemory/remember", N, C, latencies, errors, wallMs);
309
- }
310
-
311
- async function measureSmartSearch(
312
- baseUrl: string,
313
- rng: () => number,
314
- N: number,
315
- C: number,
316
- ops: number,
317
- ): Promise<CellResult> {
318
- const queries = Array.from({ length: 32 }, (_, i) => buildContent(rng, i));
319
- const { latencies, errors, wallMs } = await driveLoad(C, ops, async (i) => {
320
- const body = JSON.stringify({
321
- query: queries[i % queries.length],
322
- limit: 5,
323
- });
324
- const res = await fetch(`${baseUrl}/agentmemory/smart-search`, {
325
- method: "POST",
326
- headers: { "content-type": "application/json" },
327
- body,
328
- signal: AbortSignal.timeout(30_000),
329
- });
330
- await res.text().catch(() => "");
331
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
332
- });
333
- return summarize(
334
- "POST /agentmemory/smart-search",
335
- N,
336
- C,
337
- latencies,
338
- errors,
339
- wallMs,
340
- );
341
- }
342
-
343
- async function measureMemoriesLatest(
344
- baseUrl: string,
345
- N: number,
346
- C: number,
347
- ops: number,
348
- ): Promise<CellResult> {
349
- const { latencies, errors, wallMs } = await driveLoad(C, ops, async () => {
350
- const res = await fetch(`${baseUrl}/agentmemory/memories?latest=true`, {
351
- method: "GET",
352
- signal: AbortSignal.timeout(30_000),
353
- });
354
- await res.text().catch(() => "");
355
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
356
- });
357
- return summarize(
358
- "GET /agentmemory/memories?latest=true",
359
- N,
360
- C,
361
- latencies,
362
- errors,
363
- wallMs,
364
- );
365
- }
366
-
367
- interface RunReport {
368
- schema_version: 1;
369
- generated_at: string;
370
- git_sha: string;
371
- base_url: string;
372
- seed: number;
373
- matrix: { N: number[]; C: number[] };
374
- ops_per_cell: number;
375
- cells: CellResult[];
376
- notes: string;
377
- }
378
-
379
- async function main(): Promise<void> {
380
- const cfg = loadConfig();
381
- console.log(
382
- `[load-100k] base=${cfg.baseUrl} N=${cfg.Ns.join(",")} C=${cfg.Cs.join(",")} ops/cell=${cfg.opsPerCell} seed=${cfg.seed}`,
383
- );
384
-
385
- let daemon: ChildProcess | null = null;
386
- if (cfg.autoStart) {
387
- console.log("[load-100k] AGENTMEMORY_BENCH_AUTOSTART=1 — spawning daemon");
388
- daemon = maybeStartDaemon();
389
- }
390
-
391
- try {
392
- console.log("[load-100k] waiting for /agentmemory/livez (timeout 30s)");
393
- await waitForLivez(cfg.baseUrl, 30_000);
394
-
395
- const cells: CellResult[] = [];
396
- // N sorted ascending so each cell builds on the previous seed work.
397
- const sortedNs = cfg.Ns.slice().sort((a, b) => a - b);
398
- let seededSoFar = 0;
399
- for (const N of sortedNs) {
400
- const delta = N - seededSoFar;
401
- if (delta > 0) {
402
- console.log(
403
- `[load-100k] seeding ${delta} memories (target N=${N})`,
404
- );
405
- const rng = mulberry32(cfg.seed + seededSoFar);
406
- const seedRes = await seedMemories(cfg.baseUrl, delta, rng);
407
- seededSoFar += seedRes.seeded;
408
- console.log(
409
- `[load-100k] seeded=${seedRes.seeded} errors=${seedRes.errors} wall=${(
410
- seedRes.wallMs / 1000
411
- ).toFixed(2)}s`,
412
- );
413
- if (seedRes.errors > 0 && seedRes.seeded === 0) {
414
- throw new Error(
415
- `seeding produced 0 successes and ${seedRes.errors} errors — daemon misconfigured`,
416
- );
417
- }
418
- }
419
-
420
- for (const C of cfg.Cs) {
421
- const probeRng = mulberry32(cfg.seed ^ (N * 0x9e3779b1) ^ C);
422
-
423
- console.log(`[load-100k] cell N=${N} C=${C} remember`);
424
- const remember = await measureRemember(
425
- cfg.baseUrl,
426
- probeRng,
427
- N,
428
- C,
429
- cfg.opsPerCell,
430
- );
431
- cells.push(remember);
432
-
433
- console.log(`[load-100k] cell N=${N} C=${C} smart-search`);
434
- const search = await measureSmartSearch(
435
- cfg.baseUrl,
436
- mulberry32(cfg.seed ^ (N * 0x85ebca77) ^ C),
437
- N,
438
- C,
439
- cfg.opsPerCell,
440
- );
441
- cells.push(search);
442
-
443
- console.log(`[load-100k] cell N=${N} C=${C} memories?latest=true`);
444
- const memories = await measureMemoriesLatest(
445
- cfg.baseUrl,
446
- N,
447
- C,
448
- cfg.opsPerCell,
449
- );
450
- cells.push(memories);
451
- }
452
- }
453
-
454
- const report: RunReport = {
455
- schema_version: 1,
456
- generated_at: new Date().toISOString(),
457
- git_sha: shortGitSha(),
458
- base_url: cfg.baseUrl,
459
- seed: cfg.seed,
460
- matrix: { N: sortedNs, C: cfg.Cs.slice() },
461
- ops_per_cell: cfg.opsPerCell,
462
- cells,
463
- notes:
464
- "Single-process load harness. Latency in milliseconds. " +
465
- "Throughput is wall-clock ops/sec for the cell (concurrent in-flight = C).",
466
- };
467
-
468
- mkdirSync(cfg.outDir, { recursive: true });
469
- const outPath = join(cfg.outDir, `load-100k-${report.git_sha}.json`);
470
- writeFileSync(outPath, JSON.stringify(report, null, 2) + "\n", "utf8");
471
- console.log(`[load-100k] wrote ${outPath} (${cells.length} cells)`);
472
-
473
- // Compact table to stdout for the verification commit.
474
- console.log("");
475
- console.log(
476
- [
477
- "endpoint".padEnd(40),
478
- "N".padStart(8),
479
- "C".padStart(4),
480
- "ops".padStart(6),
481
- "err".padStart(4),
482
- "p50_ms".padStart(8),
483
- "p90_ms".padStart(8),
484
- "p99_ms".padStart(8),
485
- "tp/s".padStart(9),
486
- ].join(" "),
487
- );
488
- for (const c of cells) {
489
- console.log(
490
- [
491
- c.endpoint.padEnd(40),
492
- String(c.N).padStart(8),
493
- String(c.C).padStart(4),
494
- String(c.ops).padStart(6),
495
- String(c.errors).padStart(4),
496
- c.p50_ms.toFixed(2).padStart(8),
497
- c.p90_ms.toFixed(2).padStart(8),
498
- c.p99_ms.toFixed(2).padStart(8),
499
- c.throughput_per_sec.toFixed(2).padStart(9),
500
- ].join(" "),
501
- );
502
- }
503
- } finally {
504
- if (daemon) {
505
- daemon.kill("SIGTERM");
506
- // give it 2s to exit cleanly before SIGKILL
507
- await new Promise<void>((resolveFn) => {
508
- const t = setTimeout(() => {
509
- try {
510
- daemon!.kill("SIGKILL");
511
- } catch {
512
- /* already dead */
513
- }
514
- resolveFn();
515
- }, 2000);
516
- daemon!.once("exit", () => {
517
- clearTimeout(t);
518
- resolveFn();
519
- });
520
- });
521
- }
522
- }
523
- }
524
-
525
- main().catch((err) => {
526
- console.error("[load-100k] failed:", err instanceof Error ? err.stack : err);
527
- process.exit(1);
528
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/longmemeval-bench.ts DELETED
@@ -1,317 +0,0 @@
1
- import { SearchIndex } from "../src/state/search-index.js";
2
- import { VectorIndex } from "../src/state/vector-index.js";
3
- import { HybridSearch } from "../src/state/hybrid-search.js";
4
- import type {
5
- CompressedObservation,
6
- EmbeddingProvider,
7
- } from "../src/types.js";
8
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
9
-
10
- interface LongMemEvalEntry {
11
- question_id: string;
12
- question_type: string;
13
- question: string;
14
- question_date: string;
15
- answer: string;
16
- answer_session_ids: string[];
17
- haystack_dates: string[];
18
- haystack_session_ids: string[];
19
- haystack_sessions: Array<Array<{ role: string; content: string; has_answer?: boolean }>>;
20
- }
21
-
22
- interface SessionChunk {
23
- sessionId: string;
24
- text: string;
25
- turnCount: number;
26
- }
27
-
28
- interface BenchResult {
29
- question_id: string;
30
- question_type: string;
31
- recall_any_at_5: number;
32
- recall_any_at_10: number;
33
- recall_any_at_20: number;
34
- ndcg_at_10: number;
35
- mrr: number;
36
- retrieved_session_ids: string[];
37
- gold_session_ids: string[];
38
- }
39
-
40
- function chunkSessionToText(
41
- turns: Array<{ role: string; content: string }>,
42
- ): string {
43
- return turns
44
- .map((t) => `${t.role}: ${t.content}`)
45
- .join("\n");
46
- }
47
-
48
- function recallAny(
49
- retrievedSessionIds: string[],
50
- goldSessionIds: string[],
51
- k: number,
52
- ): number {
53
- const topK = new Set(retrievedSessionIds.slice(0, k));
54
- return goldSessionIds.some((gid) => topK.has(gid)) ? 1.0 : 0.0;
55
- }
56
-
57
- function dcg(relevances: boolean[], k: number): number {
58
- let sum = 0;
59
- for (let i = 0; i < Math.min(k, relevances.length); i++) {
60
- sum += (relevances[i] ? 1 : 0) / Math.log2(i + 2);
61
- }
62
- return sum;
63
- }
64
-
65
- function ndcg(
66
- retrievedSessionIds: string[],
67
- goldSessionIds: Set<string>,
68
- k: number,
69
- ): number {
70
- const rels = retrievedSessionIds
71
- .slice(0, k)
72
- .map((id) => goldSessionIds.has(id));
73
- const idealRels = Array.from(
74
- { length: Math.min(k, goldSessionIds.size) },
75
- () => true,
76
- );
77
- const idealDCG = dcg(idealRels, k);
78
- if (idealDCG === 0) return 0;
79
- return dcg(rels, k) / idealDCG;
80
- }
81
-
82
- function mrr(
83
- retrievedSessionIds: string[],
84
- goldSessionIds: Set<string>,
85
- ): number {
86
- for (let i = 0; i < retrievedSessionIds.length; i++) {
87
- if (goldSessionIds.has(retrievedSessionIds[i])) return 1 / (i + 1);
88
- }
89
- return 0;
90
- }
91
-
92
- class MockKV {
93
- private store = new Map<string, Map<string, unknown>>();
94
- async get<T>(scope: string, key: string): Promise<T> {
95
- const m = this.store.get(scope);
96
- if (!m || !m.has(key)) throw new Error(`Not found: ${scope}/${key}`);
97
- return m.get(key) as T;
98
- }
99
- async set(scope: string, key: string, value: unknown): Promise<void> {
100
- if (!this.store.has(scope)) this.store.set(scope, new Map());
101
- this.store.get(scope)!.set(key, value);
102
- }
103
- async list<T>(scope: string): Promise<T[]> {
104
- const m = this.store.get(scope);
105
- if (!m) return [];
106
- return Array.from(m.values()) as T[];
107
- }
108
- async delete(scope: string, key: string): Promise<void> {
109
- this.store.get(scope)?.delete(key);
110
- }
111
- }
112
-
113
- async function runBenchmark(
114
- mode: "bm25" | "vector" | "hybrid",
115
- embeddingProvider?: EmbeddingProvider,
116
- ) {
117
- const dataPath = new URL("./data/longmemeval_s_cleaned.json", import.meta.url).pathname;
118
- if (!existsSync(dataPath)) {
119
- console.error(`Dataset not found at ${dataPath}`);
120
- console.error("Download from: https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned");
121
- process.exit(1);
122
- }
123
-
124
- console.log(`Loading LongMemEval-S dataset...`);
125
- const raw = JSON.parse(readFileSync(dataPath, "utf-8")) as LongMemEvalEntry[];
126
-
127
- const abstentionTypes = new Set([
128
- "single-session-user_abs",
129
- "multi-session_abs",
130
- "knowledge-update_abs",
131
- "temporal-reasoning_abs",
132
- ]);
133
- const entries = raw.filter((e) => !abstentionTypes.has(e.question_type));
134
- console.log(
135
- `Loaded ${entries.length} questions (${raw.length - entries.length} abstention excluded)`,
136
- );
137
-
138
- const results: BenchResult[] = [];
139
- let processed = 0;
140
-
141
- for (const entry of entries) {
142
- const sessionChunks: SessionChunk[] = [];
143
- for (let i = 0; i < entry.haystack_sessions.length; i++) {
144
- const sessionId = entry.haystack_session_ids[i];
145
- const turns = entry.haystack_sessions[i];
146
- const text = chunkSessionToText(turns);
147
- sessionChunks.push({ sessionId, text, turnCount: turns.length });
148
- }
149
-
150
- const bm25 = new SearchIndex();
151
- const vector = mode !== "bm25" ? new VectorIndex() : null;
152
- const kv = new MockKV();
153
-
154
- const observations: CompressedObservation[] = [];
155
- for (const chunk of sessionChunks) {
156
- const obs: CompressedObservation = {
157
- id: `obs_${chunk.sessionId}`,
158
- sessionId: chunk.sessionId,
159
- timestamp: new Date().toISOString(),
160
- type: "conversation",
161
- title: chunk.text.slice(0, 80),
162
- facts: [],
163
- narrative: chunk.text,
164
- concepts: [],
165
- files: [],
166
- importance: 5,
167
- };
168
- observations.push(obs);
169
- bm25.add(obs);
170
-
171
- if (vector && embeddingProvider) {
172
- try {
173
- const embedding = await embeddingProvider.embed(
174
- chunk.text.slice(0, 512),
175
- );
176
- vector.add(obs.id, chunk.sessionId, embedding);
177
- } catch {}
178
- }
179
-
180
- await kv.set(`mem:obs:${chunk.sessionId}`, obs.id, obs);
181
- }
182
-
183
- let retrievedObsIds: string[];
184
-
185
- if (mode === "bm25") {
186
- const bm25Results = bm25.search(entry.question, 20);
187
- retrievedObsIds = bm25Results.map((r) => r.obsId);
188
- } else {
189
- const hybridSearch = new HybridSearch(
190
- bm25,
191
- vector,
192
- embeddingProvider || null,
193
- kv as any,
194
- 0.4,
195
- 0.6,
196
- 0.0,
197
- false,
198
- );
199
- const hybridResults = await hybridSearch.search(entry.question, 20);
200
- retrievedObsIds = hybridResults.map((r) => r.observation.id);
201
- }
202
-
203
- const retrievedSessionIds = retrievedObsIds.map((oid) =>
204
- oid.replace(/^obs_/, ""),
205
- );
206
- const goldSet = new Set(entry.answer_session_ids);
207
-
208
- const result: BenchResult = {
209
- question_id: entry.question_id,
210
- question_type: entry.question_type,
211
- recall_any_at_5: recallAny(retrievedSessionIds, entry.answer_session_ids, 5),
212
- recall_any_at_10: recallAny(retrievedSessionIds, entry.answer_session_ids, 10),
213
- recall_any_at_20: recallAny(retrievedSessionIds, entry.answer_session_ids, 20),
214
- ndcg_at_10: ndcg(retrievedSessionIds, goldSet, 10),
215
- mrr: mrr(retrievedSessionIds, goldSet),
216
- retrieved_session_ids: retrievedSessionIds.slice(0, 10),
217
- gold_session_ids: entry.answer_session_ids,
218
- };
219
- results.push(result);
220
- processed++;
221
-
222
- if (processed % 50 === 0) {
223
- const avgRecall5 =
224
- results.reduce((s, r) => s + r.recall_any_at_5, 0) / results.length;
225
- console.log(
226
- ` [${processed}/${entries.length}] running recall_any@5: ${(avgRecall5 * 100).toFixed(1)}%`,
227
- );
228
- }
229
- }
230
-
231
- const avgRecallAny5 =
232
- results.reduce((s, r) => s + r.recall_any_at_5, 0) / results.length;
233
- const avgRecallAny10 =
234
- results.reduce((s, r) => s + r.recall_any_at_10, 0) / results.length;
235
- const avgRecallAny20 =
236
- results.reduce((s, r) => s + r.recall_any_at_20, 0) / results.length;
237
- const avgNdcg10 =
238
- results.reduce((s, r) => s + r.ndcg_at_10, 0) / results.length;
239
- const avgMrr =
240
- results.reduce((s, r) => s + r.mrr, 0) / results.length;
241
-
242
- const byType = new Map<string, BenchResult[]>();
243
- for (const r of results) {
244
- if (!byType.has(r.question_type)) byType.set(r.question_type, []);
245
- byType.get(r.question_type)!.push(r);
246
- }
247
-
248
- console.log(`\n=== LongMemEval-S Results (${mode}) ===`);
249
- console.log(`Questions: ${results.length} (excl. abstention)`);
250
- console.log(`recall_any@5: ${(avgRecallAny5 * 100).toFixed(1)}%`);
251
- console.log(`recall_any@10: ${(avgRecallAny10 * 100).toFixed(1)}%`);
252
- console.log(`recall_any@20: ${(avgRecallAny20 * 100).toFixed(1)}%`);
253
- console.log(`NDCG@10: ${(avgNdcg10 * 100).toFixed(1)}%`);
254
- console.log(`MRR: ${(avgMrr * 100).toFixed(1)}%`);
255
-
256
- console.log(`\nBy question type:`);
257
- for (const [type, typeResults] of byType) {
258
- const r5 =
259
- typeResults.reduce((s, r) => s + r.recall_any_at_5, 0) /
260
- typeResults.length;
261
- const r10 =
262
- typeResults.reduce((s, r) => s + r.recall_any_at_10, 0) /
263
- typeResults.length;
264
- console.log(
265
- ` ${type.padEnd(30)} R@5: ${(r5 * 100).toFixed(1)}% R@10: ${(r10 * 100).toFixed(1)}% (n=${typeResults.length})`,
266
- );
267
- }
268
-
269
- const outPath = new URL(
270
- `./data/longmemeval_results_${mode}.json`,
271
- import.meta.url,
272
- ).pathname;
273
- writeFileSync(
274
- outPath,
275
- JSON.stringify(
276
- {
277
- mode,
278
- questions: results.length,
279
- recall_any_at_5: avgRecallAny5,
280
- recall_any_at_10: avgRecallAny10,
281
- recall_any_at_20: avgRecallAny20,
282
- ndcg_at_10: avgNdcg10,
283
- mrr: avgMrr,
284
- per_type: Object.fromEntries(
285
- Array.from(byType).map(([type, tr]) => [
286
- type,
287
- {
288
- count: tr.length,
289
- recall_any_at_5:
290
- tr.reduce((s, r) => s + r.recall_any_at_5, 0) / tr.length,
291
- recall_any_at_10:
292
- tr.reduce((s, r) => s + r.recall_any_at_10, 0) / tr.length,
293
- },
294
- ]),
295
- ),
296
- per_question: results,
297
- },
298
- null,
299
- 2,
300
- ),
301
- );
302
- console.log(`\nResults saved to ${outPath}`);
303
- }
304
-
305
- const mode = (process.argv[2] || "bm25") as "bm25" | "vector" | "hybrid";
306
- console.log(`Running LongMemEval-S benchmark in ${mode} mode...`);
307
-
308
- if (mode === "bm25") {
309
- runBenchmark("bm25").catch(console.error);
310
- } else {
311
- import("../src/providers/embedding/local.js")
312
- .then(({ LocalEmbeddingProvider }) => {
313
- const provider = new LocalEmbeddingProvider();
314
- return runBenchmark(mode, provider);
315
- })
316
- .catch(console.error);
317
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/quality-eval.ts DELETED
@@ -1,643 +0,0 @@
1
- import { SearchIndex } from "../src/state/search-index.js";
2
- import { VectorIndex } from "../src/state/vector-index.js";
3
- import { HybridSearch } from "../src/state/hybrid-search.js";
4
- import { GraphRetrieval } from "../src/functions/graph-retrieval.js";
5
- import { extractEntitiesFromQuery } from "../src/functions/query-expansion.js";
6
- import type { CompressedObservation, GraphNode, GraphEdge, GraphEdgeType } from "../src/types.js";
7
- import { generateDataset, type LabeledQuery } from "./dataset.js";
8
- import { writeFileSync } from "node:fs";
9
-
10
- interface QualityMetrics {
11
- query: string;
12
- category: string;
13
- recall_at_5: number;
14
- recall_at_10: number;
15
- recall_at_20: number;
16
- precision_at_5: number;
17
- precision_at_10: number;
18
- ndcg_at_10: number;
19
- mrr: number;
20
- relevant_count: number;
21
- retrieved_count: number;
22
- latency_ms: number;
23
- }
24
-
25
- interface SystemMetrics {
26
- system: string;
27
- avg_recall_at_5: number;
28
- avg_recall_at_10: number;
29
- avg_recall_at_20: number;
30
- avg_precision_at_5: number;
31
- avg_precision_at_10: number;
32
- avg_ndcg_at_10: number;
33
- avg_mrr: number;
34
- avg_latency_ms: number;
35
- total_tokens_per_query: number;
36
- per_query: QualityMetrics[];
37
- }
38
-
39
- function dcg(relevances: boolean[], k: number): number {
40
- let sum = 0;
41
- for (let i = 0; i < Math.min(k, relevances.length); i++) {
42
- sum += (relevances[i] ? 1 : 0) / Math.log2(i + 2);
43
- }
44
- return sum;
45
- }
46
-
47
- function ndcg(retrieved: string[], relevant: Set<string>, k: number): number {
48
- const actualRelevances = retrieved.slice(0, k).map(id => relevant.has(id));
49
- const idealRelevances = Array.from({ length: Math.min(k, relevant.size) }, () => true);
50
- const idealDCG = dcg(idealRelevances, k);
51
- if (idealDCG === 0) return 0;
52
- return dcg(actualRelevances, k) / idealDCG;
53
- }
54
-
55
- function recall(retrieved: string[], relevant: Set<string>, k: number): number {
56
- if (relevant.size === 0) return 1;
57
- const topK = new Set(retrieved.slice(0, k));
58
- let hits = 0;
59
- for (const id of relevant) {
60
- if (topK.has(id)) hits++;
61
- }
62
- return hits / relevant.size;
63
- }
64
-
65
- function precision(retrieved: string[], relevant: Set<string>, k: number): number {
66
- const topK = retrieved.slice(0, k);
67
- if (topK.length === 0) return 0;
68
- let hits = 0;
69
- for (const id of topK) {
70
- if (relevant.has(id)) hits++;
71
- }
72
- return hits / topK.length;
73
- }
74
-
75
- function mrr(retrieved: string[], relevant: Set<string>): number {
76
- for (let i = 0; i < retrieved.length; i++) {
77
- if (relevant.has(retrieved[i])) return 1 / (i + 1);
78
- }
79
- return 0;
80
- }
81
-
82
- function estimateTokens(text: string): number {
83
- return Math.ceil(text.length / 4);
84
- }
85
-
86
- function mockKV() {
87
- const store = new Map<string, Map<string, unknown>>();
88
- return {
89
- get: async <T>(scope: string, key: string): Promise<T | null> => {
90
- return (store.get(scope)?.get(key) as T) ?? null;
91
- },
92
- set: async <T>(scope: string, key: string, data: T): Promise<T> => {
93
- if (!store.has(scope)) store.set(scope, new Map());
94
- store.get(scope)!.set(key, data);
95
- return data;
96
- },
97
- delete: async (scope: string, key: string): Promise<void> => {
98
- store.get(scope)?.delete(key);
99
- },
100
- list: async <T>(scope: string): Promise<T[]> => {
101
- const entries = store.get(scope);
102
- return entries ? (Array.from(entries.values()) as T[]) : [];
103
- },
104
- };
105
- }
106
-
107
- function deterministicEmbedding(text: string, dims = 384): Float32Array {
108
- const arr = new Float32Array(dims);
109
- const words = text.toLowerCase().split(/\W+/).filter(w => w.length > 2);
110
- for (const word of words) {
111
- for (let i = 0; i < word.length; i++) {
112
- const idx = (word.charCodeAt(i) * 31 + i * 17) % dims;
113
- arr[idx] += 1;
114
- const idx2 = (word.charCodeAt(i) * 37 + i * 13 + word.length * 7) % dims;
115
- arr[idx2] += 0.5;
116
- }
117
- }
118
- const norm = Math.sqrt(arr.reduce((s, v) => s + v * v, 0));
119
- if (norm > 0) for (let i = 0; i < dims; i++) arr[i] /= norm;
120
- return arr;
121
- }
122
-
123
- async function evalBm25Only(
124
- observations: CompressedObservation[],
125
- queries: LabeledQuery[],
126
- ): Promise<SystemMetrics> {
127
- const index = new SearchIndex();
128
- for (const obs of observations) index.add(obs);
129
-
130
- const perQuery: QualityMetrics[] = [];
131
-
132
- for (const q of queries) {
133
- const relevant = new Set(q.relevantObsIds);
134
- const start = performance.now();
135
- const results = index.search(q.query, 20);
136
- const latency = performance.now() - start;
137
-
138
- const retrieved = results.map(r => r.obsId);
139
- perQuery.push({
140
- query: q.query,
141
- category: q.category,
142
- recall_at_5: recall(retrieved, relevant, 5),
143
- recall_at_10: recall(retrieved, relevant, 10),
144
- recall_at_20: recall(retrieved, relevant, 20),
145
- precision_at_5: precision(retrieved, relevant, 5),
146
- precision_at_10: precision(retrieved, relevant, 10),
147
- ndcg_at_10: ndcg(retrieved, relevant, 10),
148
- mrr: mrr(retrieved, relevant),
149
- relevant_count: relevant.size,
150
- retrieved_count: results.length,
151
- latency_ms: latency,
152
- });
153
- }
154
-
155
- const avgTokens = perQuery.reduce((sum, q) => sum + q.retrieved_count, 0) / perQuery.length;
156
- const avgObsTokens = observations.slice(0, 50).reduce((s, o) => s + estimateTokens(JSON.stringify(o)), 0) / 50;
157
-
158
- return {
159
- system: "BM25-only",
160
- avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)),
161
- avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)),
162
- avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)),
163
- avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)),
164
- avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)),
165
- avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)),
166
- avg_mrr: avg(perQuery.map(q => q.mrr)),
167
- avg_latency_ms: avg(perQuery.map(q => q.latency_ms)),
168
- total_tokens_per_query: Math.round(avgObsTokens * avgTokens),
169
- per_query: perQuery,
170
- };
171
- }
172
-
173
- async function evalDualStream(
174
- observations: CompressedObservation[],
175
- queries: LabeledQuery[],
176
- ): Promise<SystemMetrics> {
177
- const kv = mockKV();
178
- const bm25 = new SearchIndex();
179
- const vector = new VectorIndex();
180
- const dims = 384;
181
-
182
- for (const obs of observations) {
183
- bm25.add(obs);
184
- const text = [obs.title, obs.narrative, ...obs.concepts, ...obs.facts].join(" ");
185
- vector.add(obs.id, obs.sessionId, deterministicEmbedding(text, dims));
186
- await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs);
187
- }
188
-
189
- const mockEmbed: any = {
190
- name: "deterministic",
191
- dimensions: dims,
192
- embed: async (text: string) => deterministicEmbedding(text, dims),
193
- embedBatch: async (texts: string[]) => texts.map(t => deterministicEmbedding(t, dims)),
194
- };
195
-
196
- const hybrid = new HybridSearch(bm25, vector, mockEmbed, kv as never, 0.4, 0.6, 0);
197
- const perQuery: QualityMetrics[] = [];
198
-
199
- for (const q of queries) {
200
- const relevant = new Set(q.relevantObsIds);
201
- const start = performance.now();
202
- const results = await hybrid.search(q.query, 20);
203
- const latency = performance.now() - start;
204
-
205
- const retrieved = results.map(r => r.observation.id);
206
- perQuery.push({
207
- query: q.query,
208
- category: q.category,
209
- recall_at_5: recall(retrieved, relevant, 5),
210
- recall_at_10: recall(retrieved, relevant, 10),
211
- recall_at_20: recall(retrieved, relevant, 20),
212
- precision_at_5: precision(retrieved, relevant, 5),
213
- precision_at_10: precision(retrieved, relevant, 10),
214
- ndcg_at_10: ndcg(retrieved, relevant, 10),
215
- mrr: mrr(retrieved, relevant),
216
- relevant_count: relevant.size,
217
- retrieved_count: results.length,
218
- latency_ms: latency,
219
- });
220
- }
221
-
222
- const avgResultTokens = perQuery.reduce((sum, q) => {
223
- return sum + q.retrieved_count;
224
- }, 0) / perQuery.length;
225
- const avgObsTokens2 = observations.slice(0, 50).reduce((s, o) => s + estimateTokens(JSON.stringify(o)), 0) / 50;
226
-
227
- return {
228
- system: "Dual-stream (BM25+Vector)",
229
- avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)),
230
- avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)),
231
- avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)),
232
- avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)),
233
- avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)),
234
- avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)),
235
- avg_mrr: avg(perQuery.map(q => q.mrr)),
236
- avg_latency_ms: avg(perQuery.map(q => q.latency_ms)),
237
- total_tokens_per_query: Math.round(avgObsTokens2 * avgResultTokens),
238
- per_query: perQuery,
239
- };
240
- }
241
-
242
- async function evalTripleStream(
243
- observations: CompressedObservation[],
244
- queries: LabeledQuery[],
245
- ): Promise<SystemMetrics> {
246
- const kv = mockKV();
247
- const bm25 = new SearchIndex();
248
- const vector = new VectorIndex();
249
- const dims = 384;
250
-
251
- for (const obs of observations) {
252
- bm25.add(obs);
253
- const text = [obs.title, obs.narrative, ...obs.concepts, ...obs.facts].join(" ");
254
- vector.add(obs.id, obs.sessionId, deterministicEmbedding(text, dims));
255
- await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs);
256
- }
257
-
258
- const conceptToNodes = new Map<string, string>();
259
- const nodeTypes: GraphNode["type"][] = ["concept", "library", "file", "pattern"];
260
- const edgeTypes: GraphEdgeType[] = ["uses", "related_to", "depends_on", "modifies"];
261
- const now = new Date().toISOString();
262
- let nodeId = 0;
263
-
264
- for (const obs of observations) {
265
- for (const concept of obs.concepts) {
266
- if (!conceptToNodes.has(concept)) {
267
- const nid = `gn_${nodeId++}`;
268
- conceptToNodes.set(concept, nid);
269
- await kv.set("mem:graph:nodes", nid, {
270
- id: nid,
271
- type: nodeTypes[nodeId % nodeTypes.length],
272
- name: concept,
273
- properties: {},
274
- sourceObservationIds: [],
275
- createdAt: now,
276
- } as GraphNode);
277
- }
278
- const nid = conceptToNodes.get(concept)!;
279
- const existing = await kv.get<GraphNode>("mem:graph:nodes", nid);
280
- if (existing && !existing.sourceObservationIds.includes(obs.id)) {
281
- existing.sourceObservationIds.push(obs.id);
282
- await kv.set("mem:graph:nodes", nid, existing);
283
- }
284
- }
285
-
286
- const capped = obs.concepts.slice(0, 10);
287
- for (let i = 0; i < capped.length; i++) {
288
- for (let j = i + 1; j < capped.length; j++) {
289
- const srcNid = conceptToNodes.get(capped[i])!;
290
- const tgtNid = conceptToNodes.get(capped[j])!;
291
- if (srcNid && tgtNid && srcNid !== tgtNid) {
292
- const eid = `ge_${srcNid}_${tgtNid}`;
293
- const existing = await kv.get<GraphEdge>("mem:graph:edges", eid);
294
- const weight = existing ? Math.min(1.0, existing.weight + 0.1) : 0.5;
295
- await kv.set("mem:graph:edges", eid, {
296
- id: eid,
297
- type: edgeTypes[(i + j) % edgeTypes.length],
298
- sourceNodeId: srcNid,
299
- targetNodeId: tgtNid,
300
- weight,
301
- sourceObservationIds: existing
302
- ? [...new Set([...existing.sourceObservationIds, obs.id])]
303
- : [obs.id],
304
- createdAt: now,
305
- tcommit: now,
306
- version: 1,
307
- isLatest: true,
308
- } as GraphEdge);
309
- }
310
- }
311
- }
312
- }
313
-
314
- const mockEmbed: any = {
315
- name: "deterministic",
316
- dimensions: dims,
317
- embed: async (text: string) => deterministicEmbedding(text, dims),
318
- embedBatch: async (texts: string[]) => texts.map(t => deterministicEmbedding(t, dims)),
319
- };
320
-
321
- const hybrid = new HybridSearch(bm25, vector, mockEmbed, kv as never, 0.4, 0.6, 0.3);
322
- const perQuery: QualityMetrics[] = [];
323
-
324
- for (const q of queries) {
325
- const relevant = new Set(q.relevantObsIds);
326
- const start = performance.now();
327
- const results = await hybrid.search(q.query, 20);
328
- const latency = performance.now() - start;
329
-
330
- const retrieved = results.map(r => r.observation.id);
331
- perQuery.push({
332
- query: q.query,
333
- category: q.category,
334
- recall_at_5: recall(retrieved, relevant, 5),
335
- recall_at_10: recall(retrieved, relevant, 10),
336
- recall_at_20: recall(retrieved, relevant, 20),
337
- precision_at_5: precision(retrieved, relevant, 5),
338
- precision_at_10: precision(retrieved, relevant, 10),
339
- ndcg_at_10: ndcg(retrieved, relevant, 10),
340
- mrr: mrr(retrieved, relevant),
341
- relevant_count: relevant.size,
342
- retrieved_count: results.length,
343
- latency_ms: latency,
344
- });
345
- }
346
-
347
- const avgResultTokens3 = perQuery.reduce((sum, q) => {
348
- return sum + q.retrieved_count;
349
- }, 0) / perQuery.length;
350
- const avgObsTokens3 = observations.slice(0, 50).reduce((s, o) => s + estimateTokens(JSON.stringify(o)), 0) / 50;
351
-
352
- return {
353
- system: "Triple-stream (BM25+Vector+Graph)",
354
- avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)),
355
- avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)),
356
- avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)),
357
- avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)),
358
- avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)),
359
- avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)),
360
- avg_mrr: avg(perQuery.map(q => q.mrr)),
361
- avg_latency_ms: avg(perQuery.map(q => q.latency_ms)),
362
- total_tokens_per_query: Math.round(avgObsTokens3 * avgResultTokens3),
363
- per_query: perQuery,
364
- };
365
- }
366
-
367
- async function evalBuiltinMemory(
368
- observations: CompressedObservation[],
369
- queries: LabeledQuery[],
370
- ): Promise<SystemMetrics> {
371
- const allText = observations.map(o =>
372
- `## ${o.title}\n${o.narrative}\nConcepts: ${o.concepts.join(", ")}\nFiles: ${o.files.join(", ")}`
373
- ).join("\n\n");
374
-
375
- const totalTokens = estimateTokens(allText);
376
-
377
- const perQuery: QualityMetrics[] = [];
378
-
379
- for (const q of queries) {
380
- const relevant = new Set(q.relevantObsIds);
381
- const start = performance.now();
382
-
383
- const queryTerms = q.query.toLowerCase().split(/\W+/).filter(w => w.length > 2);
384
- const scored: Array<{ id: string; score: number }> = [];
385
-
386
- for (const obs of observations) {
387
- const text = [obs.title, obs.narrative, ...obs.concepts, ...obs.facts].join(" ").toLowerCase();
388
- let score = 0;
389
- for (const term of queryTerms) {
390
- if (text.includes(term)) score++;
391
- }
392
- if (score > 0) scored.push({ id: obs.id, score });
393
- }
394
-
395
- scored.sort((a, b) => b.score - a.score);
396
- const latency = performance.now() - start;
397
-
398
- const retrieved = scored.map(s => s.id).slice(0, 20);
399
- perQuery.push({
400
- query: q.query,
401
- category: q.category,
402
- recall_at_5: recall(retrieved, relevant, 5),
403
- recall_at_10: recall(retrieved, relevant, 10),
404
- recall_at_20: recall(retrieved, relevant, 20),
405
- precision_at_5: precision(retrieved, relevant, 5),
406
- precision_at_10: precision(retrieved, relevant, 10),
407
- ndcg_at_10: ndcg(retrieved, relevant, 10),
408
- mrr: mrr(retrieved, relevant),
409
- relevant_count: relevant.size,
410
- retrieved_count: Math.min(scored.length, 20),
411
- latency_ms: latency,
412
- });
413
- }
414
-
415
- return {
416
- system: "Built-in (CLAUDE.md / grep)",
417
- avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)),
418
- avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)),
419
- avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)),
420
- avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)),
421
- avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)),
422
- avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)),
423
- avg_mrr: avg(perQuery.map(q => q.mrr)),
424
- avg_latency_ms: avg(perQuery.map(q => q.latency_ms)),
425
- total_tokens_per_query: totalTokens,
426
- per_query: perQuery,
427
- };
428
- }
429
-
430
- async function evalBuiltinMemoryTruncated(
431
- observations: CompressedObservation[],
432
- queries: LabeledQuery[],
433
- ): Promise<SystemMetrics> {
434
- const MAX_LINES = 200;
435
- const lines = observations.map(o =>
436
- `- ${o.title}: ${o.narrative.slice(0, 80)}... [${o.concepts.slice(0, 3).join(", ")}]`
437
- );
438
- const truncated = lines.slice(0, MAX_LINES);
439
- const truncatedIds = new Set(observations.slice(0, MAX_LINES).map(o => o.id));
440
- const totalTokens = estimateTokens(truncated.join("\n"));
441
-
442
- const perQuery: QualityMetrics[] = [];
443
-
444
- for (const q of queries) {
445
- const relevant = new Set(q.relevantObsIds);
446
- const start = performance.now();
447
-
448
- const queryTerms = q.query.toLowerCase().split(/\W+/).filter(w => w.length > 2);
449
- const scored: Array<{ id: string; score: number }> = [];
450
-
451
- for (let i = 0; i < Math.min(MAX_LINES, observations.length); i++) {
452
- const obs = observations[i];
453
- const line = truncated[i];
454
- let score = 0;
455
- for (const term of queryTerms) {
456
- if (line.toLowerCase().includes(term)) score++;
457
- }
458
- if (score > 0) scored.push({ id: obs.id, score });
459
- }
460
-
461
- scored.sort((a, b) => b.score - a.score);
462
- const latency = performance.now() - start;
463
-
464
- const retrieved = scored.map(s => s.id).slice(0, 20);
465
-
466
- const reachableRelevant = new Set(
467
- [...relevant].filter(id => truncatedIds.has(id))
468
- );
469
-
470
- perQuery.push({
471
- query: q.query,
472
- category: q.category,
473
- recall_at_5: recall(retrieved, relevant, 5),
474
- recall_at_10: recall(retrieved, relevant, 10),
475
- recall_at_20: recall(retrieved, relevant, 20),
476
- precision_at_5: precision(retrieved, relevant, 5),
477
- precision_at_10: precision(retrieved, relevant, 10),
478
- ndcg_at_10: ndcg(retrieved, relevant, 10),
479
- mrr: mrr(retrieved, relevant),
480
- relevant_count: relevant.size,
481
- retrieved_count: Math.min(scored.length, 20),
482
- latency_ms: latency,
483
- });
484
- }
485
-
486
- return {
487
- system: "Built-in (200-line MEMORY.md)",
488
- avg_recall_at_5: avg(perQuery.map(q => q.recall_at_5)),
489
- avg_recall_at_10: avg(perQuery.map(q => q.recall_at_10)),
490
- avg_recall_at_20: avg(perQuery.map(q => q.recall_at_20)),
491
- avg_precision_at_5: avg(perQuery.map(q => q.precision_at_5)),
492
- avg_precision_at_10: avg(perQuery.map(q => q.precision_at_10)),
493
- avg_ndcg_at_10: avg(perQuery.map(q => q.ndcg_at_10)),
494
- avg_mrr: avg(perQuery.map(q => q.mrr)),
495
- avg_latency_ms: avg(perQuery.map(q => q.latency_ms)),
496
- total_tokens_per_query: totalTokens,
497
- per_query: perQuery,
498
- };
499
- }
500
-
501
- function avg(nums: number[]): number {
502
- return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
503
- }
504
-
505
- function pct(n: number): string {
506
- return (n * 100).toFixed(1) + "%";
507
- }
508
-
509
- function generateReport(systems: SystemMetrics[], obsCount: number, queryCount: number): string {
510
- const lines: string[] = [];
511
- const w = (s: string) => lines.push(s);
512
-
513
- w("# agentmemory v0.6.0 — Search Quality Evaluation");
514
- w("");
515
- w(`**Date:** ${new Date().toISOString()}`);
516
- w(`**Dataset:** ${obsCount} observations across 30 sessions (realistic coding project)`);
517
- w(`**Queries:** ${queryCount} labeled queries with ground-truth relevance`);
518
- 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)`);
519
- w("");
520
-
521
- w("## Head-to-Head Comparison");
522
- w("");
523
- w("| System | Recall@5 | Recall@10 | Precision@5 | NDCG@10 | MRR | Latency | Tokens/query |");
524
- w("|--------|----------|-----------|-------------|---------|-----|---------|--------------|");
525
- for (const s of systems) {
526
- 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()} |`);
527
- }
528
-
529
- w("");
530
- w("## Why This Matters");
531
- w("");
532
-
533
- const builtin = systems.find(s => s.system.includes("CLAUDE.md / grep"));
534
- const truncated = systems.find(s => s.system.includes("200-line"));
535
- const triple = systems.find(s => s.system.includes("Triple"));
536
- const bm25 = systems.find(s => s.system === "BM25-only");
537
-
538
- if (builtin && triple) {
539
- const recallLift = ((triple.avg_recall_at_10 - builtin.avg_recall_at_10) / Math.max(0.001, builtin.avg_recall_at_10) * 100);
540
- const tokenSaving = ((1 - triple.total_tokens_per_query / builtin.total_tokens_per_query) * 100);
541
- 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)}%)`);
542
- 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`);
543
- }
544
-
545
- if (truncated && triple) {
546
- 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.`);
547
- }
548
-
549
- w("");
550
- w("## Per-Query Breakdown (Triple-Stream)");
551
- w("");
552
-
553
- if (triple) {
554
- w("| Query | Category | Recall@10 | NDCG@10 | MRR | Relevant | Latency |");
555
- w("|-------|----------|-----------|---------|-----|----------|---------|");
556
- for (const q of triple.per_query) {
557
- 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 |`);
558
- }
559
- }
560
-
561
- w("");
562
- w("## By Query Category");
563
- w("");
564
-
565
- const categories = ["exact", "semantic", "cross-session", "entity"];
566
- if (triple) {
567
- w("| Category | Avg Recall@10 | Avg NDCG@10 | Avg MRR | Queries |");
568
- w("|----------|---------------|-------------|---------|---------|");
569
- for (const cat of categories) {
570
- const qs = triple.per_query.filter(q => q.category === cat);
571
- if (qs.length === 0) continue;
572
- 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} |`);
573
- }
574
- }
575
-
576
- w("");
577
- w("## Context Window Analysis");
578
- w("");
579
- w("The fundamental problem with built-in agent memory:");
580
- w("");
581
- w("| Observations | MEMORY.md tokens | agentmemory tokens (top 10) | Savings | MEMORY.md reachable |");
582
- w("|-------------|-----------------|---------------------------|---------|-------------------|");
583
-
584
- for (const count of [240, 500, 1000, 5000]) {
585
- const memTokens = Math.round(count * 50);
586
- const amTokens = triple ? triple.total_tokens_per_query : 500;
587
- const saving = ((1 - amTokens / memTokens) * 100);
588
- const reachable = count <= 200 ? "100%" : `${((200 / count) * 100).toFixed(0)}%`;
589
- w(`| ${count.toLocaleString()} | ${memTokens.toLocaleString()} | ${amTokens.toLocaleString()} | ${saving.toFixed(0)}% | ${reachable} |`);
590
- }
591
-
592
- w("");
593
- 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.");
594
-
595
- w("");
596
- w("---");
597
- w("");
598
- 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.*`);
599
-
600
- return lines.join("\n");
601
- }
602
-
603
- async function main() {
604
- console.log("Generating labeled dataset...");
605
- const { observations, queries, sessions } = generateDataset();
606
- console.log(`Dataset: ${observations.length} observations, ${sessions.size} sessions, ${queries.length} queries`);
607
- console.log(`Avg relevant docs per query: ${(queries.reduce((s, q) => s + q.relevantObsIds.length, 0) / queries.length).toFixed(1)}`);
608
- console.log("");
609
-
610
- console.log("Evaluating: Built-in (CLAUDE.md / grep)...");
611
- const builtinResults = await evalBuiltinMemory(observations, queries);
612
- console.log(` Recall@10: ${pct(builtinResults.avg_recall_at_10)}, NDCG@10: ${pct(builtinResults.avg_ndcg_at_10)}`);
613
-
614
- console.log("Evaluating: Built-in (200-line MEMORY.md)...");
615
- const truncatedResults = await evalBuiltinMemoryTruncated(observations, queries);
616
- console.log(` Recall@10: ${pct(truncatedResults.avg_recall_at_10)}, NDCG@10: ${pct(truncatedResults.avg_ndcg_at_10)}`);
617
-
618
- console.log("Evaluating: BM25-only...");
619
- const bm25Results = await evalBm25Only(observations, queries);
620
- console.log(` Recall@10: ${pct(bm25Results.avg_recall_at_10)}, NDCG@10: ${pct(bm25Results.avg_ndcg_at_10)}`);
621
-
622
- console.log("Evaluating: Dual-stream (BM25+Vector)...");
623
- const dualResults = await evalDualStream(observations, queries);
624
- console.log(` Recall@10: ${pct(dualResults.avg_recall_at_10)}, NDCG@10: ${pct(dualResults.avg_ndcg_at_10)}`);
625
-
626
- console.log("Evaluating: Triple-stream (BM25+Vector+Graph)...");
627
- const tripleResults = await evalTripleStream(observations, queries);
628
- console.log(` Recall@10: ${pct(tripleResults.avg_recall_at_10)}, NDCG@10: ${pct(tripleResults.avg_ndcg_at_10)}`);
629
-
630
- console.log("");
631
-
632
- const report = generateReport(
633
- [builtinResults, truncatedResults, bm25Results, dualResults, tripleResults],
634
- observations.length,
635
- queries.length,
636
- );
637
-
638
- writeFileSync("benchmark/QUALITY.md", report);
639
- console.log(report);
640
- console.log(`\nReport written to benchmark/QUALITY.md`);
641
- }
642
-
643
- main().catch(console.error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/real-embeddings-eval.ts DELETED
@@ -1,405 +0,0 @@
1
- import { SearchIndex } from "../src/state/search-index.js";
2
- import { VectorIndex } from "../src/state/vector-index.js";
3
- import { HybridSearch } from "../src/state/hybrid-search.js";
4
- import { LocalEmbeddingProvider } from "../src/providers/embedding/local.js";
5
- import type { CompressedObservation, EmbeddingProvider } from "../src/types.js";
6
- import { generateDataset, type LabeledQuery } from "./dataset.js";
7
- import { writeFileSync } from "node:fs";
8
-
9
- function mockKV() {
10
- const store = new Map<string, Map<string, unknown>>();
11
- return {
12
- get: async <T>(scope: string, key: string): Promise<T | null> =>
13
- (store.get(scope)?.get(key) as T) ?? null,
14
- set: async <T>(scope: string, key: string, data: T): Promise<T> => {
15
- if (!store.has(scope)) store.set(scope, new Map());
16
- store.get(scope)!.set(key, data);
17
- return data;
18
- },
19
- delete: async (scope: string, key: string): Promise<void> => {
20
- store.get(scope)?.delete(key);
21
- },
22
- list: async <T>(scope: string): Promise<T[]> => {
23
- const entries = store.get(scope);
24
- return entries ? (Array.from(entries.values()) as T[]) : [];
25
- },
26
- };
27
- }
28
-
29
- function estimateTokens(text: string): number {
30
- return Math.ceil(text.length / 4);
31
- }
32
-
33
- function obsToText(obs: CompressedObservation): string {
34
- return [obs.title, obs.subtitle || "", obs.narrative, ...obs.facts, ...obs.concepts].join(" ");
35
- }
36
-
37
- function recall(retrieved: string[], relevant: Set<string>, k: number): number {
38
- if (relevant.size === 0) return 1;
39
- const topK = new Set(retrieved.slice(0, k));
40
- let hits = 0;
41
- for (const id of relevant) if (topK.has(id)) hits++;
42
- return hits / relevant.size;
43
- }
44
-
45
- function precision(retrieved: string[], relevant: Set<string>, k: number): number {
46
- const topK = retrieved.slice(0, k);
47
- if (topK.length === 0) return 0;
48
- let hits = 0;
49
- for (const id of topK) if (relevant.has(id)) hits++;
50
- return hits / topK.length;
51
- }
52
-
53
- function dcg(relevances: boolean[], k: number): number {
54
- let sum = 0;
55
- for (let i = 0; i < Math.min(k, relevances.length); i++)
56
- sum += (relevances[i] ? 1 : 0) / Math.log2(i + 2);
57
- return sum;
58
- }
59
-
60
- function ndcg(retrieved: string[], relevant: Set<string>, k: number): number {
61
- const actual = retrieved.slice(0, k).map(id => relevant.has(id));
62
- const ideal = Array.from({ length: Math.min(k, relevant.size) }, () => true);
63
- const idealDCG = dcg(ideal, k);
64
- return idealDCG === 0 ? 0 : dcg(actual, k) / idealDCG;
65
- }
66
-
67
- function mrr(retrieved: string[], relevant: Set<string>): number {
68
- for (let i = 0; i < retrieved.length; i++)
69
- if (relevant.has(retrieved[i])) return 1 / (i + 1);
70
- return 0;
71
- }
72
-
73
- function avg(nums: number[]): number {
74
- return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
75
- }
76
-
77
- function pct(n: number): string {
78
- return (n * 100).toFixed(1) + "%";
79
- }
80
-
81
- interface QueryResult {
82
- query: string;
83
- category: string;
84
- recall_5: number;
85
- recall_10: number;
86
- precision_5: number;
87
- ndcg_10: number;
88
- mrr_val: number;
89
- relevant_count: number;
90
- latency_ms: number;
91
- }
92
-
93
- interface SystemResult {
94
- name: string;
95
- results: QueryResult[];
96
- embed_time_ms: number;
97
- tokens_per_query: number;
98
- }
99
-
100
- async function evalSystem(
101
- name: string,
102
- observations: CompressedObservation[],
103
- queries: LabeledQuery[],
104
- provider: EmbeddingProvider | null,
105
- weights: { bm25: number; vector: number; graph: number },
106
- ): Promise<SystemResult> {
107
- const kv = mockKV();
108
- const bm25 = new SearchIndex();
109
- const vector = provider ? new VectorIndex() : null;
110
-
111
- console.log(` Indexing ${observations.length} observations...`);
112
- const embedStart = performance.now();
113
-
114
- for (const obs of observations) {
115
- bm25.add(obs);
116
- await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs);
117
- }
118
-
119
- if (provider && vector) {
120
- const batchSize = 32;
121
- for (let i = 0; i < observations.length; i += batchSize) {
122
- const batch = observations.slice(i, i + batchSize);
123
- const texts = batch.map(o => obsToText(o));
124
- const embeddings = await provider.embedBatch(texts);
125
- for (let j = 0; j < batch.length; j++) {
126
- vector.add(batch[j].id, batch[j].sessionId, embeddings[j]);
127
- }
128
- if ((i + batchSize) % 100 === 0 || i + batchSize >= observations.length) {
129
- process.stdout.write(`\r Embedded ${Math.min(i + batchSize, observations.length)}/${observations.length}`);
130
- }
131
- }
132
- console.log("");
133
- }
134
-
135
- const embedTime = performance.now() - embedStart;
136
-
137
- const hybrid = new HybridSearch(
138
- bm25,
139
- vector,
140
- provider,
141
- kv as never,
142
- weights.bm25,
143
- weights.vector,
144
- weights.graph,
145
- );
146
-
147
- console.log(` Running ${queries.length} queries...`);
148
- const results: QueryResult[] = [];
149
-
150
- for (const q of queries) {
151
- const relevant = new Set(q.relevantObsIds);
152
- const start = performance.now();
153
- const searchResults = await hybrid.search(q.query, 20);
154
- const latency = performance.now() - start;
155
-
156
- const retrieved = searchResults.map(r => r.observation.id);
157
- results.push({
158
- query: q.query,
159
- category: q.category,
160
- recall_5: recall(retrieved, relevant, 5),
161
- recall_10: recall(retrieved, relevant, 10),
162
- precision_5: precision(retrieved, relevant, 5),
163
- ndcg_10: ndcg(retrieved, relevant, 10),
164
- mrr_val: mrr(retrieved, relevant),
165
- relevant_count: relevant.size,
166
- latency_ms: latency,
167
- });
168
- }
169
-
170
- let totalReturnedTokens = 0;
171
- for (const q of queries) {
172
- const searchResults = await hybrid.search(q.query, 10);
173
- totalReturnedTokens += searchResults.reduce(
174
- (sum, r) => sum + estimateTokens(JSON.stringify(r.observation)),
175
- 0,
176
- );
177
- }
178
- const avgReturnedTokens = Math.round(totalReturnedTokens / queries.length);
179
-
180
- return {
181
- name,
182
- results,
183
- embed_time_ms: embedTime,
184
- tokens_per_query: avgReturnedTokens,
185
- };
186
- }
187
-
188
- async function evalBuiltinGrep(
189
- observations: CompressedObservation[],
190
- queries: LabeledQuery[],
191
- ): Promise<SystemResult> {
192
- const results: QueryResult[] = [];
193
-
194
- for (const q of queries) {
195
- const relevant = new Set(q.relevantObsIds);
196
- const queryTerms = q.query.toLowerCase().split(/\W+/).filter(w => w.length > 2);
197
- const start = performance.now();
198
-
199
- const scored: Array<{ id: string; score: number }> = [];
200
- for (const obs of observations) {
201
- const text = [obs.title, obs.narrative, ...obs.concepts, ...obs.facts].join(" ").toLowerCase();
202
- let score = 0;
203
- for (const term of queryTerms) if (text.includes(term)) score++;
204
- if (score > 0) scored.push({ id: obs.id, score });
205
- }
206
- scored.sort((a, b) => b.score - a.score);
207
- const latency = performance.now() - start;
208
-
209
- const retrieved = scored.map(s => s.id).slice(0, 20);
210
- results.push({
211
- query: q.query,
212
- category: q.category,
213
- recall_5: recall(retrieved, relevant, 5),
214
- recall_10: recall(retrieved, relevant, 10),
215
- precision_5: precision(retrieved, relevant, 5),
216
- ndcg_10: ndcg(retrieved, relevant, 10),
217
- mrr_val: mrr(retrieved, relevant),
218
- relevant_count: relevant.size,
219
- latency_ms: latency,
220
- });
221
- }
222
-
223
- const allTokens = estimateTokens(observations.map(o =>
224
- `## ${o.title}\n${o.narrative}\nConcepts: ${o.concepts.join(", ")}`
225
- ).join("\n\n"));
226
-
227
- return { name: "Built-in (grep all)", results, embed_time_ms: 0, tokens_per_query: allTokens };
228
- }
229
-
230
- function generateReport(systems: SystemResult[], obsCount: number): string {
231
- const lines: string[] = [];
232
- const w = (s: string) => lines.push(s);
233
-
234
- w("# agentmemory v0.6.0 — Real Embeddings Quality Evaluation");
235
- w("");
236
- w(`**Date:** ${new Date().toISOString()}`);
237
- w(`**Platform:** ${process.platform} ${process.arch}, Node ${process.version}`);
238
- w(`**Dataset:** ${obsCount} observations, 30 sessions, 20 labeled queries`);
239
- w(`**Embedding model:** Xenova/all-MiniLM-L6-v2 (384d, local, no API key)`);
240
- w("");
241
-
242
- w("## Head-to-Head: Real Embeddings vs Keyword Search");
243
- w("");
244
- w("| System | Recall@5 | Recall@10 | Precision@5 | NDCG@10 | MRR | Avg Latency | Tokens/query |");
245
- w("|--------|----------|-----------|-------------|---------|-----|-------------|--------------|");
246
-
247
- for (const s of systems) {
248
- const r = s.results;
249
- 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()} |`);
250
- }
251
-
252
- w("");
253
- w("## Improvement from Real Embeddings");
254
- w("");
255
-
256
- const bm25Only = systems.find(s => s.name === "BM25-only (stemmed+synonyms)");
257
- const dual = systems.find(s => s.name.includes("Dual-stream"));
258
- const triple = systems.find(s => s.name.includes("Triple-stream"));
259
- const builtin = systems.find(s => s.name.includes("grep"));
260
-
261
- if (bm25Only && dual) {
262
- const recallDelta = avg(dual.results.map(q => q.recall_10)) - avg(bm25Only.results.map(q => q.recall_10));
263
- w(`Adding real vector embeddings to BM25 improves recall@10 by **${(recallDelta * 100).toFixed(1)} percentage points**.`);
264
- }
265
- if (builtin && dual) {
266
- const tokenSaving = (1 - dual.tokens_per_query / builtin.tokens_per_query) * 100;
267
- w(`Token savings vs loading everything: **${tokenSaving.toFixed(0)}%** (${dual.tokens_per_query.toLocaleString()} vs ${builtin.tokens_per_query.toLocaleString()} tokens).`);
268
- }
269
-
270
- w("");
271
- w("## Per-Query: Where Real Embeddings Win");
272
- w("");
273
-
274
- if (bm25Only && dual) {
275
- w("Queries where dual-stream (real embeddings) outperforms BM25-only:");
276
- w("");
277
- w("| Query | Category | BM25 Recall@10 | +Vector Recall@10 | Delta |");
278
- w("|-------|----------|---------------|-------------------|-------|");
279
-
280
- for (let i = 0; i < bm25Only.results.length; i++) {
281
- const bq = bm25Only.results[i];
282
- const dq = dual.results[i];
283
- const delta = dq.recall_10 - bq.recall_10;
284
- const marker = delta > 0 ? " **" : delta < 0 ? " *" : "";
285
- if (Math.abs(delta) > 0.001) {
286
- 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} |`);
287
- }
288
- }
289
- }
290
-
291
- w("");
292
- w("## By Category Comparison");
293
- w("");
294
- const categories = ["exact", "semantic", "cross-session", "entity"];
295
-
296
- w("| Category | Built-in grep | BM25 (stemmed) | +Real Vectors | +Graph |");
297
- w("|----------|--------------|----------------|--------------|--------|");
298
-
299
- for (const cat of categories) {
300
- const vals = systems.map(s => {
301
- const qs = s.results.filter(q => q.category === cat);
302
- return qs.length ? pct(avg(qs.map(q => q.recall_10))) : "-";
303
- });
304
- w(`| ${cat} | ${vals.join(" | ")} |`);
305
- }
306
-
307
- w("");
308
- w("## Embedding Performance");
309
- w("");
310
- w("| System | Embedding Time | Model | Dimensions |");
311
- w("|--------|---------------|-------|------------|");
312
- for (const s of systems) {
313
- if (s.embed_time_ms > 100) {
314
- w(`| ${s.name} | ${(s.embed_time_ms / 1000).toFixed(1)}s | Xenova/all-MiniLM-L6-v2 | 384 |`);
315
- }
316
- }
317
- w("");
318
- w("Embedding is a one-time cost at ingestion. Search is sub-millisecond after indexing.");
319
-
320
- w("");
321
- w("## Key Findings");
322
- w("");
323
-
324
- if (bm25Only && dual) {
325
- const semBm25 = bm25Only.results.filter(q => q.category === "semantic");
326
- const semDual = dual.results.filter(q => q.category === "semantic");
327
- const semImprove = avg(semDual.map(q => q.recall_10)) - avg(semBm25.map(q => q.recall_10));
328
-
329
- w(`1. **Semantic queries improve most**: ${(semImprove * 100).toFixed(1)}pp recall@10 gain from real embeddings`);
330
- 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)}`);
331
- w(`3. **Entity/exact queries** are already well-served by BM25+stemming — vectors add marginal value`);
332
- w(`4. **Local embeddings (Xenova)** run without API keys — zero cost, zero latency concerns`);
333
- }
334
-
335
- w("");
336
- w("## Recommendation");
337
- w("");
338
- w("Enable local embeddings by default (`EMBEDDING_PROVIDER=local` or install `@xenova/transformers`).");
339
- w("This gives agentmemory genuine semantic search that built-in agent memories cannot match —");
340
- w("understanding that \"database performance optimization\" relates to \"N+1 query fix\" and \"eager loading\".");
341
- w("");
342
-
343
- w("---");
344
- w(`*All measurements use Xenova/all-MiniLM-L6-v2 local embeddings (384 dimensions, no API calls).*`);
345
-
346
- return lines.join("\n");
347
- }
348
-
349
- async function main() {
350
- console.log("=== agentmemory Real Embeddings Benchmark ===\n");
351
-
352
- console.log("Loading Xenova/all-MiniLM-L6-v2 model (first run downloads ~80MB)...");
353
- let provider: EmbeddingProvider;
354
- try {
355
- provider = new LocalEmbeddingProvider();
356
- const testEmbed = await provider.embed("test");
357
- console.log(`Model loaded. Dimensions: ${testEmbed.length}\n`);
358
- } catch (err) {
359
- console.error("Failed to load Xenova model:", err);
360
- console.error("Install with: npm install @xenova/transformers");
361
- process.exit(1);
362
- }
363
-
364
- const { observations, queries } = generateDataset();
365
- console.log(`Dataset: ${observations.length} observations, ${queries.length} queries\n`);
366
-
367
- console.log("1. Built-in (grep all)...");
368
- const builtinResult = await evalBuiltinGrep(observations, queries);
369
- console.log(` Recall@10: ${pct(avg(builtinResult.results.map(q => q.recall_10)))}\n`);
370
-
371
- console.log("2. BM25-only (stemmed+synonyms)...");
372
- const bm25Result = await evalSystem(
373
- "BM25-only (stemmed+synonyms)",
374
- observations, queries, null,
375
- { bm25: 1.0, vector: 0, graph: 0 },
376
- );
377
- console.log(` Recall@10: ${pct(avg(bm25Result.results.map(q => q.recall_10)))}\n`);
378
-
379
- console.log("3. Dual-stream (BM25 + real Xenova vectors)...");
380
- const dualResult = await evalSystem(
381
- "Dual-stream (BM25+Xenova)",
382
- observations, queries, provider,
383
- { bm25: 0.4, vector: 0.6, graph: 0 },
384
- );
385
- console.log(` Recall@10: ${pct(avg(dualResult.results.map(q => q.recall_10)))}\n`);
386
-
387
- console.log("4. Triple-stream (BM25 + Xenova + Graph)...");
388
- const tripleResult = await evalSystem(
389
- "Triple-stream (BM25+Xenova+Graph)",
390
- observations, queries, provider,
391
- { bm25: 0.4, vector: 0.6, graph: 0.3 },
392
- );
393
- console.log(` Recall@10: ${pct(avg(tripleResult.results.map(q => q.recall_10)))}\n`);
394
-
395
- const report = generateReport(
396
- [builtinResult, bm25Result, dualResult, tripleResult],
397
- observations.length,
398
- );
399
-
400
- writeFileSync("benchmark/REAL-EMBEDDINGS.md", report);
401
- console.log(report);
402
- console.log(`\nReport written to benchmark/REAL-EMBEDDINGS.md`);
403
- }
404
-
405
- main().catch(console.error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/results/load-100k-96c0ed0.json DELETED
@@ -1,61 +0,0 @@
1
- {
2
- "schema_version": 1,
3
- "generated_at": "2026-05-13T19:49:26.116Z",
4
- "git_sha": "96c0ed0",
5
- "base_url": "http://localhost:3111",
6
- "seed": 12648430,
7
- "matrix": {
8
- "N": [
9
- 1000
10
- ],
11
- "C": [
12
- 10
13
- ]
14
- },
15
- "ops_per_cell": 200,
16
- "cells": [
17
- {
18
- "endpoint": "POST /agentmemory/remember",
19
- "N": 1000,
20
- "C": 10,
21
- "ops": 200,
22
- "errors": 0,
23
- "wall_ms": 11504.64,
24
- "throughput_per_sec": 17.38,
25
- "p50_ms": 577.435,
26
- "p90_ms": 607.335,
27
- "p99_ms": 675.269,
28
- "min_ms": 64.46,
29
- "max_ms": 683.164
30
- },
31
- {
32
- "endpoint": "POST /agentmemory/smart-search",
33
- "N": 1000,
34
- "C": 10,
35
- "ops": 200,
36
- "errors": 0,
37
- "wall_ms": 3264.572,
38
- "throughput_per_sec": 61.26,
39
- "p50_ms": 160.064,
40
- "p90_ms": 185.608,
41
- "p99_ms": 224.354,
42
- "min_ms": 98.498,
43
- "max_ms": 251.317
44
- },
45
- {
46
- "endpoint": "GET /agentmemory/memories?latest=true",
47
- "N": 1000,
48
- "C": 10,
49
- "ops": 200,
50
- "errors": 0,
51
- "wall_ms": 8051.764,
52
- "throughput_per_sec": 24.84,
53
- "p50_ms": 395.462,
54
- "p90_ms": 475.714,
55
- "p99_ms": 542.648,
56
- "min_ms": 158.79,
57
- "max_ms": 635.331
58
- }
59
- ],
60
- "notes": "Single-process load harness. Latency in milliseconds. Throughput is wall-clock ops/sec for the cell (concurrent in-flight = C)."
61
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
benchmark/scale-eval.ts DELETED
@@ -1,398 +0,0 @@
1
- import { SearchIndex } from "../src/state/search-index.js";
2
- import { VectorIndex } from "../src/state/vector-index.js";
3
- import { HybridSearch } from "../src/state/hybrid-search.js";
4
- import type { CompressedObservation } from "../src/types.js";
5
- import { generateScaleDataset, generateDataset } from "./dataset.js";
6
- import { writeFileSync } from "node:fs";
7
-
8
- function mockKV() {
9
- const store = new Map<string, Map<string, unknown>>();
10
- return {
11
- get: async <T>(scope: string, key: string): Promise<T | null> =>
12
- (store.get(scope)?.get(key) as T) ?? null,
13
- set: async <T>(scope: string, key: string, data: T): Promise<T> => {
14
- if (!store.has(scope)) store.set(scope, new Map());
15
- store.get(scope)!.set(key, data);
16
- return data;
17
- },
18
- delete: async (scope: string, key: string): Promise<void> => {
19
- store.get(scope)?.delete(key);
20
- },
21
- list: async <T>(scope: string): Promise<T[]> => {
22
- const entries = store.get(scope);
23
- return entries ? (Array.from(entries.values()) as T[]) : [];
24
- },
25
- };
26
- }
27
-
28
- function deterministicEmbedding(text: string, dims = 384): Float32Array {
29
- const arr = new Float32Array(dims);
30
- const words = text.toLowerCase().split(/\W+/).filter(w => w.length > 2);
31
- for (const word of words) {
32
- for (let i = 0; i < word.length; i++) {
33
- const idx = (word.charCodeAt(i) * 31 + i * 17) % dims;
34
- arr[idx] += 1;
35
- const idx2 = (word.charCodeAt(i) * 37 + i * 13 + word.length * 7) % dims;
36
- arr[idx2] += 0.5;
37
- }
38
- }
39
- const norm = Math.sqrt(arr.reduce((s, v) => s + v * v, 0));
40
- if (norm > 0) for (let i = 0; i < dims; i++) arr[i] /= norm;
41
- return arr;
42
- }
43
-
44
- function estimateTokens(text: string): number {
45
- return Math.ceil(text.length / 4);
46
- }
47
-
48
- interface ScaleResult {
49
- scale: number;
50
- sessions: number;
51
- index_build_ms: number;
52
- index_build_per_doc_ms: number;
53
- bm25_search_ms: number;
54
- hybrid_search_ms: number;
55
- index_size_kb: number;
56
- vector_size_kb: number;
57
- heap_mb: number;
58
- builtin_tokens: number;
59
- builtin_200line_tokens: number;
60
- agentmemory_tokens: number;
61
- token_savings_pct: number;
62
- builtin_unreachable_pct: number;
63
- }
64
-
65
- interface CrossSessionResult {
66
- query: string;
67
- target_session: string;
68
- current_session: string;
69
- sessions_apart: number;
70
- bm25_found: boolean;
71
- bm25_rank: number;
72
- hybrid_found: boolean;
73
- hybrid_rank: number;
74
- builtin_found: boolean;
75
- latency_ms: number;
76
- }
77
-
78
- const SEARCH_QUERIES = [
79
- "authentication middleware JWT",
80
- "PostgreSQL connection pooling",
81
- "Kubernetes pod crash",
82
- "rate limiting API",
83
- "Playwright E2E tests",
84
- "Docker multi-stage build",
85
- "Redis caching layer",
86
- "CI/CD GitHub Actions",
87
- "Prisma migration drift",
88
- "monitoring Datadog alerts",
89
- ];
90
-
91
- async function benchmarkScale(counts: number[]): Promise<ScaleResult[]> {
92
- const results: ScaleResult[] = [];
93
-
94
- for (const count of counts) {
95
- console.log(` Scale: ${count.toLocaleString()} observations...`);
96
- const observations = generateScaleDataset(count);
97
- const sessionCount = new Set(observations.map(o => o.sessionId)).size;
98
-
99
- const heapBefore = process.memoryUsage().heapUsed;
100
-
101
- const buildStart = performance.now();
102
- const bm25 = new SearchIndex();
103
- const vector = new VectorIndex();
104
- const kv = mockKV();
105
- const dims = 384;
106
-
107
- for (const obs of observations) {
108
- bm25.add(obs);
109
- const text = [obs.title, obs.narrative, ...obs.concepts].join(" ");
110
- vector.add(obs.id, obs.sessionId, deterministicEmbedding(text, dims));
111
- await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs);
112
- }
113
- const buildMs = performance.now() - buildStart;
114
-
115
- const heapAfter = process.memoryUsage().heapUsed;
116
-
117
- const mockEmbed: any = {
118
- name: "deterministic", dimensions: dims,
119
- embed: async (t: string) => deterministicEmbedding(t, dims),
120
- embedBatch: async (ts: string[]) => ts.map(t => deterministicEmbedding(t, dims)),
121
- };
122
- const hybrid = new HybridSearch(bm25, vector, mockEmbed, kv as never, 0.4, 0.6, 0);
123
-
124
- let bm25Total = 0;
125
- let hybridTotal = 0;
126
- const iters = 20;
127
-
128
- for (let i = 0; i < iters; i++) {
129
- const q = SEARCH_QUERIES[i % SEARCH_QUERIES.length];
130
- const s1 = performance.now();
131
- bm25.search(q, 10);
132
- bm25Total += performance.now() - s1;
133
-
134
- const s2 = performance.now();
135
- await hybrid.search(q, 10);
136
- hybridTotal += performance.now() - s2;
137
- }
138
-
139
- const bm25Ser = bm25.serialize();
140
- const vecSer = vector.serialize();
141
-
142
- const allText = observations.map(o =>
143
- `- ${o.title}: ${o.narrative.slice(0, 80)}... [${o.concepts.slice(0, 3).join(", ")}]`
144
- ).join("\n");
145
- const builtinTokens = estimateTokens(allText);
146
-
147
- const truncatedText = observations.slice(0, 200).map(o =>
148
- `- ${o.title}: ${o.narrative.slice(0, 60)}... [${o.concepts.slice(0, 3).join(", ")}]`
149
- ).join("\n");
150
- const builtin200Tokens = estimateTokens(truncatedText);
151
-
152
- let totalResultTokens = 0;
153
- for (let i = 0; i < iters; i++) {
154
- const q = SEARCH_QUERIES[i % SEARCH_QUERIES.length];
155
- const results = await hybrid.search(q, 10);
156
- totalResultTokens += estimateTokens(JSON.stringify(results.map(r => r.observation)));
157
- }
158
- const agentmemoryTokens = Math.round(totalResultTokens / iters);
159
-
160
- results.push({
161
- scale: count,
162
- sessions: sessionCount,
163
- index_build_ms: Math.round(buildMs),
164
- index_build_per_doc_ms: +(buildMs / count).toFixed(3),
165
- bm25_search_ms: +(bm25Total / iters).toFixed(3),
166
- hybrid_search_ms: +(hybridTotal / iters).toFixed(3),
167
- index_size_kb: Math.round(Buffer.byteLength(bm25Ser, "utf-8") / 1024),
168
- vector_size_kb: Math.round(Buffer.byteLength(vecSer, "utf-8") / 1024),
169
- heap_mb: Math.round((heapAfter - heapBefore) / 1024 / 1024),
170
- builtin_tokens: builtinTokens,
171
- builtin_200line_tokens: builtin200Tokens,
172
- agentmemory_tokens: agentmemoryTokens,
173
- token_savings_pct: Math.round((1 - agentmemoryTokens / builtinTokens) * 100),
174
- builtin_unreachable_pct: count <= 200 ? 0 : Math.round((1 - 200 / count) * 100),
175
- });
176
- }
177
-
178
- return results;
179
- }
180
-
181
- async function benchmarkCrossSession(): Promise<CrossSessionResult[]> {
182
- const { observations } = generateDataset();
183
- const results: CrossSessionResult[] = [];
184
-
185
- const bm25 = new SearchIndex();
186
- const kv = mockKV();
187
- const vector = new VectorIndex();
188
- const dims = 384;
189
-
190
- for (const obs of observations) {
191
- bm25.add(obs);
192
- const text = [obs.title, obs.narrative, ...obs.concepts].join(" ");
193
- vector.add(obs.id, obs.sessionId, deterministicEmbedding(text, dims));
194
- await kv.set(`mem:obs:${obs.sessionId}`, obs.id, obs);
195
- }
196
-
197
- const mockEmbed: any = {
198
- name: "deterministic", dimensions: dims,
199
- embed: async (t: string) => deterministicEmbedding(t, dims),
200
- embedBatch: async (ts: string[]) => ts.map(t => deterministicEmbedding(t, dims)),
201
- };
202
- const hybrid = new HybridSearch(bm25, vector, mockEmbed, kv as never, 0.4, 0.6, 0);
203
-
204
- const crossQueries: Array<{
205
- query: string;
206
- targetConcepts: string[];
207
- targetSessionRange: [number, number];
208
- currentSession: number;
209
- }> = [
210
- { query: "How did we set up OAuth providers?", targetConcepts: ["oauth", "nextauth"], targetSessionRange: [5, 9], currentSession: 29 },
211
- { query: "What was the N+1 query fix?", targetConcepts: ["n+1", "eager-loading"], targetSessionRange: [10, 14], currentSession: 28 },
212
- { query: "PostgreSQL full-text search setup", targetConcepts: ["full-text-search", "tsvector"], targetSessionRange: [10, 14], currentSession: 27 },
213
- { query: "bcrypt password hashing configuration", targetConcepts: ["bcrypt", "password-hashing"], targetSessionRange: [5, 9], currentSession: 25 },
214
- { query: "Vitest unit testing setup", targetConcepts: ["vitest", "unit-testing"], targetSessionRange: [20, 24], currentSession: 29 },
215
- { query: "webhook retry exponential backoff", targetConcepts: ["webhooks", "exponential-backoff"], targetSessionRange: [15, 19], currentSession: 29 },
216
- { query: "ESLint flat config migration", targetConcepts: ["eslint", "linting"], targetSessionRange: [0, 4], currentSession: 29 },
217
- { query: "Kubernetes HPA autoscaling configuration", targetConcepts: ["hpa", "autoscaling", "kubernetes"], targetSessionRange: [25, 29], currentSession: 29 },
218
- { query: "Prisma database seed script", targetConcepts: ["seeding", "faker", "prisma"], targetSessionRange: [10, 14], currentSession: 26 },
219
- { query: "API cursor-based pagination", targetConcepts: ["cursor-based", "pagination"], targetSessionRange: [15, 19], currentSession: 29 },
220
- { query: "CSRF protection double-submit cookie", targetConcepts: ["csrf", "cookies"], targetSessionRange: [5, 9], currentSession: 29 },
221
- { query: "blue-green deployment rollback", targetConcepts: ["blue-green", "rollback", "zero-downtime"], targetSessionRange: [25, 29], currentSession: 29 },
222
- ];
223
-
224
- for (const cq of crossQueries) {
225
- const targetObs = observations.filter(o =>
226
- o.concepts.some(c => cq.targetConcepts.includes(c))
227
- );
228
- const targetIds = new Set(targetObs.map(o => o.id));
229
-
230
- const start = performance.now();
231
- const bm25Results = bm25.search(cq.query, 20);
232
- const hybridResults = await hybrid.search(cq.query, 20);
233
- const latency = performance.now() - start;
234
-
235
- const bm25Rank = bm25Results.findIndex(r => targetIds.has(r.obsId));
236
- const hybridRank = hybridResults.findIndex(r => targetIds.has(r.observation.id));
237
-
238
- const builtinLines = 200;
239
- const visibleObs = observations.slice(0, builtinLines);
240
- const builtinFound = visibleObs.some(o => targetIds.has(o.id));
241
-
242
- const sessionsApart = cq.currentSession - cq.targetSessionRange[0];
243
-
244
- results.push({
245
- query: cq.query,
246
- target_session: `ses_${cq.targetSessionRange[0].toString().padStart(3, "0")}-${cq.targetSessionRange[1].toString().padStart(3, "0")}`,
247
- current_session: `ses_${cq.currentSession.toString().padStart(3, "0")}`,
248
- sessions_apart: sessionsApart,
249
- bm25_found: bm25Rank >= 0,
250
- bm25_rank: bm25Rank >= 0 ? bm25Rank + 1 : -1,
251
- hybrid_found: hybridRank >= 0,
252
- hybrid_rank: hybridRank >= 0 ? hybridRank + 1 : -1,
253
- builtin_found: builtinFound,
254
- latency_ms: latency,
255
- });
256
- }
257
-
258
- return results;
259
- }
260
-
261
- function generateReport(scale: ScaleResult[], cross: CrossSessionResult[]): string {
262
- const lines: string[] = [];
263
- const w = (s: string) => lines.push(s);
264
-
265
- w("# agentmemory v0.6.0 — Scale & Cross-Session Evaluation");
266
- w("");
267
- w(`**Date:** ${new Date().toISOString()}`);
268
- w(`**Platform:** ${process.platform} ${process.arch}, Node ${process.version}`);
269
- w("");
270
-
271
- w("## 1. Scale: agentmemory vs Built-in Memory");
272
- w("");
273
- 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.");
274
- w("");
275
- w("| Observations | Sessions | Index Build | BM25 Search | Hybrid Search | Heap | Context Tokens (built-in) | Context Tokens (agentmemory) | Savings | Built-in Unreachable |");
276
- w("|-------------|----------|------------|-------------|---------------|------|--------------------------|-----------------------------|---------|--------------------|");
277
-
278
- for (const r of scale) {
279
- 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}% |`);
280
- }
281
-
282
- w("");
283
- w("### What the numbers mean");
284
- w("");
285
- 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.");
286
- w("");
287
- w("**Context Tokens (agentmemory):** How many tokens the top-10 search results consume. Stays constant regardless of corpus size.");
288
- w("");
289
- 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.");
290
- w("");
291
-
292
- w("### Storage Costs");
293
- w("");
294
- w("| Observations | BM25 Index | Vector Index (d=384) | Total Storage |");
295
- w("|-------------|-----------|---------------------|---------------|");
296
- for (const r of scale) {
297
- const total = r.index_size_kb + r.vector_size_kb;
298
- w(`| ${r.scale.toLocaleString()} | ${r.index_size_kb.toLocaleString()} KB | ${r.vector_size_kb.toLocaleString()} KB | ${(total / 1024).toFixed(1)} MB |`);
299
- }
300
-
301
- w("");
302
- w("## 2. Cross-Session Retrieval");
303
- w("");
304
- w("Can the system find relevant information from past sessions? This is impossible for built-in memory once observations exceed the line/context cap.");
305
- w("");
306
- w("| Query | Target Session | Gap | BM25 Found | BM25 Rank | Hybrid Found | Hybrid Rank | Built-in Visible |");
307
- w("|-------|---------------|-----|-----------|-----------|-------------|-------------|-----------------|");
308
-
309
- for (const r of cross) {
310
- 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"} |`);
311
- }
312
-
313
- const bm25Found = cross.filter(r => r.bm25_found).length;
314
- const hybridFound = cross.filter(r => r.hybrid_found).length;
315
- const builtinFound = cross.filter(r => r.builtin_found).length;
316
-
317
- w("");
318
- 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}.`);
319
-
320
- w("");
321
- w("## 3. The Context Window Problem");
322
- w("");
323
- w("```");
324
- w("Agent context window: ~200K tokens");
325
- w("System prompt + tools: ~20K tokens");
326
- w("User conversation: ~30K tokens");
327
- w("Available for memory: ~150K tokens");
328
- w("");
329
- w("At 50 tokens/observation:");
330
- w(" 200 observations = 10,000 tokens (fits, but 200-line cap hits first)");
331
- w(" 1,000 observations = 50,000 tokens (33% of available budget)");
332
- w(" 5,000 observations = 250,000 tokens (EXCEEDS total context window)");
333
- w("");
334
- w("agentmemory top-10 results:");
335
- w(` Any corpus size = ~${scale[0]?.agentmemory_tokens.toLocaleString() || "500"} tokens (0.3% of budget)`);
336
- w("```");
337
- w("");
338
-
339
- w("## 4. What Built-in Memory Cannot Do");
340
- w("");
341
- w("| Capability | Built-in (CLAUDE.md) | agentmemory |");
342
- w("|-----------|---------------------|-------------|");
343
- w("| Semantic search | No (keyword grep only) | BM25 + vector + graph |");
344
- w("| Scale beyond 200 lines | No (hard cap) | Unlimited |");
345
- w("| Cross-session recall | Only if in 200-line window | Full corpus search |");
346
- w("| Cross-agent sharing | No (per-agent files) | MCP + REST API |");
347
- w("| Multi-agent coordination | No | Leases, signals, actions |");
348
- w("| Temporal queries | No | Point-in-time graph |");
349
- w("| Memory lifecycle | No (manual pruning) | Ebbinghaus decay + eviction |");
350
- w("| Knowledge graph | No | Entity extraction + traversal |");
351
- w("| Query expansion | No | LLM-generated reformulations |");
352
- w("| Retention scoring | No | Time-frequency decay model |");
353
- w("| Real-time dashboard | No (read files manually) | Viewer on :3113 |");
354
- w("| Concurrent access | No (file lock) | Keyed mutex + KV store |");
355
- w("");
356
-
357
- w("## 5. When to Use What");
358
- w("");
359
- w("**Use built-in memory (CLAUDE.md) when:**");
360
- w("- You have < 200 items to remember");
361
- w("- Single agent, single project");
362
- w("- Preferences and quick facts only");
363
- w("- Zero setup is the priority");
364
- w("");
365
- w("**Use agentmemory when:**");
366
- w("- Project history exceeds 200 observations");
367
- w("- You need to recall specific incidents from weeks ago");
368
- w("- Multiple agents work on the same codebase");
369
- w("- You want semantic search (\"how does auth work?\") not just keyword matching");
370
- w("- You need to track memory quality, decay, and lifecycle");
371
- w("- You want a shared memory layer across Claude Code, Cursor, Windsurf, etc.");
372
- w("");
373
- w("Built-in memory is your sticky notes. agentmemory is the searchable database behind them.");
374
- w("");
375
-
376
- w("---");
377
- w(`*Scale tests: ${scale.length} corpus sizes. Cross-session tests: ${cross.length} queries targeting specific past sessions.*`);
378
-
379
- return lines.join("\n");
380
- }
381
-
382
- async function main() {
383
- console.log("=== agentmemory Scale & Cross-Session Evaluation ===\n");
384
-
385
- console.log("1. Scale benchmarks...");
386
- const scaleResults = await benchmarkScale([240, 1_000, 5_000, 10_000, 50_000]);
387
-
388
- console.log("\n2. Cross-session retrieval...");
389
- const crossResults = await benchmarkCrossSession();
390
-
391
- console.log("");
392
- const report = generateReport(scaleResults, crossResults);
393
- writeFileSync("benchmark/SCALE.md", report);
394
- console.log(report);
395
- console.log(`\nReport written to benchmark/SCALE.md`);
396
- }
397
-
398
- main().catch(console.error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/README.md DELETED
@@ -1,111 +0,0 @@
1
- # agentmemory-evals
2
-
3
- Public benchmarks for agentmemory's hybrid memory stack (BM25 + embeddings + consolidation + graph).
4
-
5
- Two families, both reproducible:
6
-
7
- - **LongMemEval** — public 500-question retrieval benchmark over multi-session chat
8
- - **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
9
-
10
- ## Adapters
11
-
12
- | Adapter | Backend | API key needed |
13
- |---|---|---|
14
- | `grep` | Tokenized substring match | none |
15
- | `vector` | OpenAI `text-embedding-3-small` + cosine | `OPENAI_API_KEY` |
16
- | `agentmemory` | Running agentmemory server, smart-search endpoint | none (auth optional via `AGENTMEMORY_SECRET`) |
17
-
18
- ## Sandbox first
19
-
20
- 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.
21
-
22
- `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.
23
-
24
- ```sh
25
- source eval/scripts/sandbox.sh
26
- npm run eval:coding-life -- --adapters grep,agentmemory
27
- ```
28
-
29
- 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`:
30
-
31
- ```sh
32
- mkdir -p ~/.local/bin
33
- 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
34
- export PATH="$HOME/.local/bin:$PATH" # add to ~/.zshrc or ~/.bashrc for persistence
35
- ```
36
-
37
- ## Quickstart
38
-
39
- ### coding-agent-life-v1 (in-house, no download)
40
-
41
- ```sh
42
- # grep baseline, no sandbox needed
43
- npm run eval:coding-life -- --adapters grep
44
-
45
- # add agentmemory + vector (sandbox + OpenAI key)
46
- source eval/scripts/sandbox.sh
47
- OPENAI_API_KEY=sk-... npm run eval:coding-life -- --adapters grep,vector,agentmemory
48
- ```
49
-
50
- ### LongMemEval `_s` (public, 278MB download)
51
-
52
- ```sh
53
- mkdir -p ~/datasets/longmemeval
54
- curl -Lo ~/datasets/longmemeval/longmemeval_s.json \
55
- https://huggingface.co/datasets/xiaowu0162/longmemeval/resolve/main/longmemeval_s
56
-
57
- source eval/scripts/sandbox.sh
58
-
59
- # Stratified sample of 10 per type (fast iteration, ~$0.20 OpenAI cost)
60
- OPENAI_API_KEY=sk-... LONGMEMEVAL_PATH=~/datasets/longmemeval/longmemeval_s.json \
61
- npm run eval:longmemeval -- --stratify 10
62
-
63
- # Full 500 questions × 3 adapters (~$2 OpenAI cost)
64
- OPENAI_API_KEY=sk-... LONGMEMEVAL_PATH=~/datasets/longmemeval/longmemeval_s.json \
65
- npm run eval:longmemeval
66
- ```
67
-
68
- ## Repo layout
69
-
70
- ```text
71
- eval/
72
- ├── README.md
73
- ├── runner/
74
- │ ├── types.ts Adapter, Question, RankedDoc, ScoreRow
75
- │ ├── score.ts P@K, R@K, aggregation
76
- │ ├── load.ts LongMemEval JSON → Question[]
77
- │ ├── adapters/
78
- │ │ ├── grep.ts tokenized substring baseline
79
- │ │ ├── vector.ts OpenAI embeddings + cosine
80
- │ │ └── agentmemory.ts POST /agentmemory/{remember,smart-search}
81
- │ ├── longmemeval.ts public benchmark runner
82
- │ └── coding-life.ts in-house benchmark runner
83
- └── data/
84
- └── coding-agent-life-v1/
85
- ├── sessions.json 15 fictional sessions (~6KB)
86
- └── queries.json 15 queries with gold session IDs
87
- ```
88
-
89
- Reports land in `eval/reports/<bench>/` (gitignored): `scores.ndjson` + `summary.json`.
90
-
91
- Published scorecards land in `docs/benchmarks/YYYY-MM-DD-<bench>.md`.
92
-
93
- ## Writing a new adapter
94
-
95
- 1. Implement `Adapter<State>` from `eval/runner/types.ts`:
96
- ```ts
97
- import type { Adapter } from "../types.js";
98
- export const myAdapter: Adapter<MyState> = {
99
- name: "my-adapter",
100
- async init(sessions, config) { /* index */ return state; },
101
- async query(q, state, k) { /* search */ return ranked; },
102
- };
103
- ```
104
- 2. Register in `eval/runner/{longmemeval,coding-life}.ts` `ADAPTERS` map.
105
- 3. Run against `coding-agent-life-v1` to sanity-check before committing OpenAI spend on LongMemEval.
106
-
107
- ## Why a benchmark for agentmemory
108
-
109
- 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.
110
-
111
- 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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/data/coding-agent-life-v1/queries.json DELETED
@@ -1,107 +0,0 @@
1
- [
2
- {
3
- "id": "q-001",
4
- "type": "single-session-bug",
5
- "question": "Where did we land the auth env var precedence fix?",
6
- "answer": "PR #11 with SHIPCTL_TOKEN > SHIP_TOKEN > SC_TOKEN precedence",
7
- "goldSessionIds": ["sess-001"]
8
- },
9
- {
10
- "id": "q-002",
11
- "type": "single-session-infra",
12
- "question": "What was the multi-arch Docker fix?",
13
- "answer": "Added --platform=$BUILDPLATFORM and BUILDX_PLATFORMS for amd64+arm64",
14
- "goldSessionIds": ["sess-002"]
15
- },
16
- {
17
- "id": "q-003",
18
- "type": "single-session-refactor",
19
- "question": "Where did we consolidate the retry logic?",
20
- "answer": "src/retry.rs with exponential backoff base=200ms cap=30s full jitter",
21
- "goldSessionIds": ["sess-003"]
22
- },
23
- {
24
- "id": "q-004",
25
- "type": "single-session-feature",
26
- "question": "Which PR introduced helm chart support?",
27
- "answer": "PR #14",
28
- "goldSessionIds": ["sess-004"]
29
- },
30
- {
31
- "id": "q-005",
32
- "type": "single-session-test",
33
- "question": "Which test was flaky on macos and how was it fixed?",
34
- "answer": "fs-watcher emits_changekind_file_delete; bumped wait to 1500ms + retry: 2",
35
- "goldSessionIds": ["sess-005"]
36
- },
37
- {
38
- "id": "q-006",
39
- "type": "single-session-perf",
40
- "question": "How did we fix the memory leak?",
41
- "answer": "Replaced unbounded HashMap with LruCache cap=10k in src/cache.rs (PR #16)",
42
- "goldSessionIds": ["sess-006"]
43
- },
44
- {
45
- "id": "q-007",
46
- "type": "single-session-api",
47
- "question": "How did we handle the github API rate limit?",
48
- "answer": "Conditional requests with If-None-Match etag and 304 caching via http-cache",
49
- "goldSessionIds": ["sess-007"]
50
- },
51
- {
52
- "id": "q-008",
53
- "type": "single-session-db",
54
- "question": "What was the schema migration approach for run_history?",
55
- "answer": "Three-phase: nullable column + dual-write, backfill + flip reads, drop old column",
56
- "goldSessionIds": ["sess-008"]
57
- },
58
- {
59
- "id": "q-009",
60
- "type": "single-session-infra",
61
- "question": "How is the docs site deployed?",
62
- "answer": "GitHub Actions docs.yml workflow + mdbook build + Cloudflare Pages on shipctl.dev",
63
- "goldSessionIds": ["sess-009"]
64
- },
65
- {
66
- "id": "q-010",
67
- "type": "single-session-release",
68
- "question": "Which PR set up the cross-platform release pipeline?",
69
- "answer": "PR #19 with cross-rs for linux and native macos/windows builds",
70
- "goldSessionIds": ["sess-010"]
71
- },
72
- {
73
- "id": "q-011",
74
- "type": "multi-session-causal",
75
- "question": "What was the root cause of the staging incident, and where was it fixed?",
76
- "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)",
77
- "goldSessionIds": ["sess-001", "sess-014"]
78
- },
79
- {
80
- "id": "q-012",
81
- "type": "preference",
82
- "question": "Which async runtime does the team prefer for new code?",
83
- "answer": "async-std (decided in arch review, not tokio)",
84
- "goldSessionIds": ["sess-015"]
85
- },
86
- {
87
- "id": "q-013",
88
- "type": "preference",
89
- "question": "What are the user's formatting preferences?",
90
- "answer": "2-space indent in yaml, tab in rust, rust-analyzer over rls, cargo clippy before commit",
91
- "goldSessionIds": ["sess-012"]
92
- },
93
- {
94
- "id": "q-014",
95
- "type": "multi-session-review",
96
- "question": "What was the race condition Aria fixed in the schema migration?",
97
- "answer": "Dual-write step had race when two writers hit same row; Aria added per-row advisory lock",
98
- "goldSessionIds": ["sess-008", "sess-013"]
99
- },
100
- {
101
- "id": "q-015",
102
- "type": "temporal",
103
- "question": "What was shipped on April 8th 2026?",
104
- "answer": "Auth env-var precedence fix in PR #11, then started docker arm64 work",
105
- "goldSessionIds": ["sess-001", "sess-011"]
106
- }
107
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/data/coding-agent-life-v1/sessions.json DELETED
@@ -1,77 +0,0 @@
1
- [
2
- {
3
- "id": "sess-001",
4
- "timestamp": "2026-04-08T09:14:00Z",
5
- "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."
6
- },
7
- {
8
- "id": "sess-002",
9
- "timestamp": "2026-04-08T14:30:00Z",
10
- "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."
11
- },
12
- {
13
- "id": "sess-003",
14
- "timestamp": "2026-04-09T10:02:00Z",
15
- "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."
16
- },
17
- {
18
- "id": "sess-004",
19
- "timestamp": "2026-04-10T11:45:00Z",
20
- "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."
21
- },
22
- {
23
- "id": "sess-005",
24
- "timestamp": "2026-04-11T16:20:00Z",
25
- "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."
26
- },
27
- {
28
- "id": "sess-006",
29
- "timestamp": "2026-04-12T09:50:00Z",
30
- "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."
31
- },
32
- {
33
- "id": "sess-007",
34
- "timestamp": "2026-04-13T13:15:00Z",
35
- "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."
36
- },
37
- {
38
- "id": "sess-008",
39
- "timestamp": "2026-04-15T10:30:00Z",
40
- "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."
41
- },
42
- {
43
- "id": "sess-009",
44
- "timestamp": "2026-04-16T15:40:00Z",
45
- "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."
46
- },
47
- {
48
- "id": "sess-010",
49
- "timestamp": "2026-04-17T11:00:00Z",
50
- "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."
51
- },
52
- {
53
- "id": "sess-011",
54
- "timestamp": "2026-04-08T17:00:00Z",
55
- "content": "[user] standup notes: shipped #11 auth fix this morning. starting on docker arm64 next."
56
- },
57
- {
58
- "id": "sess-012",
59
- "timestamp": "2026-04-10T18:30:00Z",
60
- "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."
61
- },
62
- {
63
- "id": "sess-013",
64
- "timestamp": "2026-04-15T19:00:00Z",
65
- "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."
66
- },
67
- {
68
- "id": "sess-014",
69
- "timestamp": "2026-04-16T20:10:00Z",
70
- "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."
71
- },
72
- {
73
- "id": "sess-015",
74
- "timestamp": "2026-04-17T16:45:00Z",
75
- "content": "[user] preferences: stick to async-std not tokio for new code. team agreed in arch review."
76
- }
77
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/runner/adapters/agentmemory.ts DELETED
@@ -1,93 +0,0 @@
1
- import type { Adapter, RankedDoc, Session } from "../types.js";
2
-
3
- interface AgentMemoryState {
4
- baseUrl: string;
5
- secret?: string;
6
- sessions: Session[];
7
- observationToSession: Map<string, string>;
8
- }
9
-
10
- interface RememberResponse {
11
- memory?: { id?: string };
12
- observationId?: string;
13
- id?: string;
14
- observation?: { id?: string };
15
- }
16
-
17
- interface SmartSearchResponse {
18
- results?: Array<{
19
- obsId?: string;
20
- id?: string;
21
- observationId?: string;
22
- sessionId?: string;
23
- score?: number;
24
- content?: string;
25
- }>;
26
- observations?: Array<{
27
- obsId?: string;
28
- id?: string;
29
- sessionId?: string;
30
- score?: number;
31
- content?: string;
32
- }>;
33
- }
34
-
35
- function authHeaders(secret?: string): Record<string, string> {
36
- const h: Record<string, string> = { "Content-Type": "application/json" };
37
- if (secret) h.Authorization = `Bearer ${secret}`;
38
- return h;
39
- }
40
-
41
- export const agentmemoryAdapter: Adapter<AgentMemoryState> = {
42
- name: "agentmemory-hybrid",
43
- async init(sessions, config) {
44
- const baseUrl = (config?.baseUrl as string) ?? process.env.AGENTMEMORY_BASE_URL ?? "http://localhost:3111";
45
- const secret = (config?.secret as string) ?? process.env.AGENTMEMORY_SECRET;
46
- const observationToSession = new Map<string, string>();
47
- for (const s of sessions) {
48
- const res = await fetch(`${baseUrl}/agentmemory/remember`, {
49
- method: "POST",
50
- headers: authHeaders(secret),
51
- body: JSON.stringify({
52
- content: s.content,
53
- type: "eval-session",
54
- concepts: [s.id],
55
- }),
56
- });
57
- if (!res.ok) {
58
- throw new Error(`remember failed for ${s.id}: ${res.status} ${await res.text()}`);
59
- }
60
- const body = (await res.json()) as RememberResponse;
61
- const obsId =
62
- body.memory?.id ?? body.observationId ?? body.id ?? body.observation?.id;
63
- if (obsId) observationToSession.set(obsId, s.id);
64
- }
65
- return { baseUrl, secret, sessions, observationToSession };
66
- },
67
- async query(q, state, k) {
68
- const res = await fetch(`${state.baseUrl}/agentmemory/smart-search`, {
69
- method: "POST",
70
- headers: authHeaders(state.secret),
71
- body: JSON.stringify({ query: q, limit: Math.max(k * 10, 50) }),
72
- });
73
- if (!res.ok) {
74
- throw new Error(`smart-search failed: ${res.status} ${await res.text()}`);
75
- }
76
- const body = (await res.json()) as SmartSearchResponse;
77
- const rows = body.results ?? body.observations ?? [];
78
- const ranked: RankedDoc[] = [];
79
- const seen = new Set<string>();
80
- for (const row of rows) {
81
- let sessionId = row.sessionId;
82
- if (!sessionId) {
83
- const memId = row.obsId ?? row.id ?? row.observationId;
84
- sessionId = memId ? state.observationToSession.get(memId) : undefined;
85
- }
86
- if (!sessionId || seen.has(sessionId)) continue;
87
- seen.add(sessionId);
88
- ranked.push({ sessionId, score: row.score ?? 0 });
89
- if (ranked.length >= k) break;
90
- }
91
- return ranked;
92
- },
93
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/runner/adapters/grep.ts DELETED
@@ -1,36 +0,0 @@
1
- import type { Adapter, RankedDoc, Session } from "../types.js";
2
-
3
- interface GrepState {
4
- sessions: Session[];
5
- }
6
-
7
- function tokenize(s: string): string[] {
8
- return s
9
- .toLowerCase()
10
- .replace(/[^a-z0-9_]+/g, " ")
11
- .split(/\s+/)
12
- .filter((t) => t.length > 2);
13
- }
14
-
15
- export const grepAdapter: Adapter<GrepState> = {
16
- name: "grep",
17
- async init(sessions) {
18
- return { sessions };
19
- },
20
- async query(q, state, k) {
21
- const terms = tokenize(q);
22
- const scored: RankedDoc[] = [];
23
- for (const s of state.sessions) {
24
- const body = s.content.toLowerCase();
25
- let hits = 0;
26
- for (const t of terms) {
27
- if (body.includes(t)) hits += 1;
28
- }
29
- if (hits > 0) {
30
- scored.push({ sessionId: s.id, score: hits });
31
- }
32
- }
33
- scored.sort((a, b) => b.score - a.score);
34
- return scored.slice(0, k);
35
- },
36
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/runner/adapters/vector.ts DELETED
@@ -1,108 +0,0 @@
1
- import type { Adapter, RankedDoc, Session } from "../types.js";
2
-
3
- interface VectorState {
4
- sessions: Session[];
5
- embeddings: Float32Array[];
6
- }
7
-
8
- const OPENAI_URL = "https://api.openai.com/v1/embeddings";
9
- const MODEL = "text-embedding-3-small";
10
- const DIM = 1536;
11
-
12
- async function embed(text: string, apiKey: string): Promise<Float32Array> {
13
- const res = await fetch(OPENAI_URL, {
14
- method: "POST",
15
- headers: {
16
- "Content-Type": "application/json",
17
- Authorization: `Bearer ${apiKey}`,
18
- },
19
- body: JSON.stringify({ input: text, model: MODEL }),
20
- });
21
- if (!res.ok) {
22
- throw new Error(`OpenAI embed failed: ${res.status} ${await res.text()}`);
23
- }
24
- const data = (await res.json()) as { data: Array<{ embedding: number[] }> };
25
- return Float32Array.from(data.data[0].embedding);
26
- }
27
-
28
- async function embedBatch(texts: string[], apiKey: string): Promise<Float32Array[]> {
29
- const res = await fetch(OPENAI_URL, {
30
- method: "POST",
31
- headers: {
32
- "Content-Type": "application/json",
33
- Authorization: `Bearer ${apiKey}`,
34
- },
35
- body: JSON.stringify({ input: texts, model: MODEL }),
36
- });
37
- if (!res.ok) {
38
- throw new Error(`OpenAI batch embed failed: ${res.status} ${await res.text()}`);
39
- }
40
- const data = (await res.json()) as { data: Array<{ embedding: number[]; index: number }> };
41
- if (!Array.isArray(data.data) || data.data.length !== texts.length) {
42
- throw new Error(
43
- `OpenAI batch embed: expected ${texts.length} embeddings, got ${data.data?.length ?? 0}`,
44
- );
45
- }
46
- const out = new Array<Float32Array>(texts.length);
47
- for (const row of data.data) {
48
- if (
49
- !Number.isInteger(row.index) ||
50
- row.index < 0 ||
51
- row.index >= texts.length ||
52
- out[row.index] !== undefined
53
- ) {
54
- throw new Error(`OpenAI batch embed: invalid or duplicate index ${row.index}`);
55
- }
56
- if (!Array.isArray(row.embedding) || row.embedding.length === 0) {
57
- throw new Error(`OpenAI batch embed: empty embedding at index ${row.index}`);
58
- }
59
- out[row.index] = Float32Array.from(row.embedding);
60
- }
61
- return out;
62
- }
63
-
64
- function cosine(a: Float32Array, b: Float32Array): number {
65
- let dot = 0;
66
- let na = 0;
67
- let nb = 0;
68
- for (let i = 0; i < a.length; i++) {
69
- dot += a[i] * b[i];
70
- na += a[i] * a[i];
71
- nb += b[i] * b[i];
72
- }
73
- const denom = Math.sqrt(na) * Math.sqrt(nb);
74
- return denom === 0 ? 0 : dot / denom;
75
- }
76
-
77
- export const vectorAdapter: Adapter<VectorState> = {
78
- name: "vector",
79
- async init(sessions) {
80
- const apiKey = process.env.OPENAI_API_KEY;
81
- if (!apiKey) throw new Error("OPENAI_API_KEY required for vector adapter");
82
- const embeddings: Float32Array[] = new Array(sessions.length);
83
- const BATCH = 50;
84
- for (let i = 0; i < sessions.length; i += BATCH) {
85
- const batch = sessions.slice(i, i + BATCH);
86
- const vecs = await embedBatch(
87
- batch.map((s) => s.content.slice(0, 8000)),
88
- apiKey,
89
- );
90
- for (let j = 0; j < vecs.length; j++) embeddings[i + j] = vecs[j];
91
- }
92
- if (embeddings.length > 0 && embeddings[0].length !== DIM) {
93
- throw new Error(`unexpected embedding dim: ${embeddings[0].length}`);
94
- }
95
- return { sessions, embeddings };
96
- },
97
- async query(q, state, k) {
98
- const apiKey = process.env.OPENAI_API_KEY;
99
- if (!apiKey) throw new Error("OPENAI_API_KEY required for vector adapter");
100
- const qvec = await embed(q, apiKey);
101
- const scored: RankedDoc[] = state.sessions.map((s, i) => ({
102
- sessionId: s.id,
103
- score: cosine(qvec, state.embeddings[i]),
104
- }));
105
- scored.sort((a, b) => b.score - a.score);
106
- return scored.slice(0, k);
107
- },
108
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/runner/coding-life.ts DELETED
@@ -1,101 +0,0 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { parseArgs } from "node:util";
4
- import { agentmemoryAdapter } from "./adapters/agentmemory.js";
5
- import { grepAdapter } from "./adapters/grep.js";
6
- import { vectorAdapter } from "./adapters/vector.js";
7
- import { aggregate, scoreQuestion } from "./score.js";
8
- import type { Adapter, Question, ScoreRow, Session } from "./types.js";
9
-
10
- const ADAPTERS: Record<string, Adapter> = {
11
- grep: grepAdapter as unknown as Adapter,
12
- vector: vectorAdapter as unknown as Adapter,
13
- agentmemory: agentmemoryAdapter as unknown as Adapter,
14
- };
15
-
16
- interface CliOptions {
17
- data: string;
18
- adapters: string;
19
- k: string;
20
- out: string;
21
- }
22
-
23
- function parse(): CliOptions {
24
- const { values } = parseArgs({
25
- options: {
26
- data: { type: "string", default: "eval/data/coding-agent-life-v1" },
27
- adapters: { type: "string", default: "grep,vector,agentmemory" },
28
- k: { type: "string", default: "5" },
29
- out: { type: "string", default: "eval/reports/coding-life" },
30
- },
31
- });
32
- return values as unknown as CliOptions;
33
- }
34
-
35
- async function main(): Promise<void> {
36
- const opts = parse();
37
- const k = Number(opts.k);
38
- if (!Number.isInteger(k) || k <= 0) {
39
- console.error(`--k must be a positive integer, got: ${opts.k}`);
40
- process.exit(2);
41
- }
42
- const sessions = JSON.parse(
43
- readFileSync(resolve(opts.data, "sessions.json"), "utf8"),
44
- ) as Session[];
45
- const queriesRaw = JSON.parse(
46
- readFileSync(resolve(opts.data, "queries.json"), "utf8"),
47
- ) as Array<Omit<Question, "haystack">>;
48
- const questions: Question[] = queriesRaw.map((q) => ({ ...q, haystack: sessions }));
49
- const adapterNames = opts.adapters.split(",").map((s) => s.trim()).filter(Boolean);
50
- for (const a of adapterNames) {
51
- if (!ADAPTERS[a]) {
52
- console.error(`unknown adapter: ${a}. options: ${Object.keys(ADAPTERS).join(",")}`);
53
- process.exit(2);
54
- }
55
- }
56
- console.log(
57
- `loaded ${sessions.length} sessions, ${questions.length} queries, adapters: ${adapterNames.join(",")}, k=${k}`,
58
- );
59
-
60
- const outDir = resolve(opts.out);
61
- mkdirSync(outDir, { recursive: true });
62
- const ndjsonPath = `${outDir}/scores.ndjson`;
63
- if (existsSync(ndjsonPath)) writeFileSync(ndjsonPath, "");
64
-
65
- const rows: ScoreRow[] = [];
66
- for (const adapterName of adapterNames) {
67
- const adapter = ADAPTERS[adapterName];
68
- console.log(`\n== ${adapter.name} ==`);
69
- const state = await adapter.init(sessions);
70
- try {
71
- for (const q of questions) {
72
- const t0 = performance.now();
73
- const ranked = await adapter.query(q.question, state, k);
74
- const latencyMs = performance.now() - t0;
75
- const row = scoreQuestion(q, ranked, k, adapter.name, latencyMs);
76
- rows.push(row);
77
- appendFileSync(ndjsonPath, JSON.stringify(row) + "\n");
78
- const mark = row.hit ? "+" : "-";
79
- console.log(
80
- ` ${mark} ${q.id} [${q.type}] R@${k}=${row.recallAtK.toFixed(2)} (${Math.round(latencyMs)}ms)`,
81
- );
82
- }
83
- } finally {
84
- if (adapter.teardown) await adapter.teardown(state);
85
- }
86
- }
87
-
88
- const agg = aggregate(rows);
89
- writeFileSync(`${outDir}/summary.json`, JSON.stringify(agg, null, 2));
90
- console.log("\n=== Summary ===");
91
- for (const [adapter, stats] of Object.entries(agg.byAdapter)) {
92
- console.log(
93
- ` ${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`,
94
- );
95
- }
96
- }
97
-
98
- main().catch((err) => {
99
- console.error(err);
100
- process.exit(1);
101
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/runner/load.ts DELETED
@@ -1,54 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import type { Question, Session } from "./types.js";
3
-
4
- interface LongMemEvalRaw {
5
- question_id: string;
6
- question_type: string;
7
- question: string;
8
- answer?: string;
9
- answer_session_ids: string[];
10
- haystack_session_ids: string[];
11
- haystack_sessions: Array<Array<{ role: string; content: string }>>;
12
- }
13
-
14
- function flattenSession(turns: Array<{ role: string; content: string }>): string {
15
- return turns.map((t) => `[${t.role}] ${t.content}`).join("\n\n");
16
- }
17
-
18
- export function loadLongMemEval(path: string, limit?: number): Question[] {
19
- const raw = JSON.parse(readFileSync(path, "utf8")) as LongMemEvalRaw[];
20
- const slice = typeof limit === "number" ? raw.slice(0, limit) : raw;
21
- const questions: Question[] = [];
22
- for (const r of slice) {
23
- if (r.haystack_session_ids.length !== r.haystack_sessions.length) {
24
- throw new Error(
25
- `LongMemEval row ${r.question_id}: haystack_session_ids (${r.haystack_session_ids.length}) and haystack_sessions (${r.haystack_sessions.length}) length mismatch`,
26
- );
27
- }
28
- const haystack: Session[] = r.haystack_session_ids.map((id, i) => ({
29
- id,
30
- content: flattenSession(r.haystack_sessions[i]),
31
- }));
32
- questions.push({
33
- id: r.question_id,
34
- type: r.question_type,
35
- question: r.question,
36
- answer: r.answer,
37
- goldSessionIds: r.answer_session_ids,
38
- haystack,
39
- });
40
- }
41
- return questions;
42
- }
43
-
44
- export function stratifySample(questions: Question[], perType: number): Question[] {
45
- const buckets: Record<string, Question[]> = {};
46
- for (const q of questions) {
47
- (buckets[q.type] ??= []).push(q);
48
- }
49
- const out: Question[] = [];
50
- for (const type of Object.keys(buckets).sort()) {
51
- out.push(...buckets[type].slice(0, perType));
52
- }
53
- return out;
54
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/runner/longmemeval.ts DELETED
@@ -1,126 +0,0 @@
1
- import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
2
- import { dirname, resolve } from "node:path";
3
- import { parseArgs } from "node:util";
4
- import { agentmemoryAdapter } from "./adapters/agentmemory.js";
5
- import { grepAdapter } from "./adapters/grep.js";
6
- import { vectorAdapter } from "./adapters/vector.js";
7
- import { loadLongMemEval, stratifySample } from "./load.js";
8
- import { aggregate, scoreQuestion } from "./score.js";
9
- import type { Adapter, ScoreRow } from "./types.js";
10
-
11
- const ADAPTERS: Record<string, Adapter> = {
12
- grep: grepAdapter as unknown as Adapter,
13
- vector: vectorAdapter as unknown as Adapter,
14
- agentmemory: agentmemoryAdapter as unknown as Adapter,
15
- };
16
-
17
- interface CliOptions {
18
- data: string;
19
- adapters: string;
20
- k: string;
21
- limit?: string;
22
- stratify?: string;
23
- out: string;
24
- }
25
-
26
- function parse(): CliOptions {
27
- const { values } = parseArgs({
28
- options: {
29
- data: { type: "string", default: process.env.LONGMEMEVAL_PATH ?? "" },
30
- adapters: { type: "string", default: "grep,vector,agentmemory" },
31
- k: { type: "string", default: "5" },
32
- limit: { type: "string" },
33
- stratify: { type: "string" },
34
- out: { type: "string", default: "eval/reports/longmemeval" },
35
- },
36
- });
37
- return values as unknown as CliOptions;
38
- }
39
-
40
- async function main(): Promise<void> {
41
- const opts = parse();
42
- if (!opts.data) {
43
- console.error("--data <path/to/longmemeval_s.json> required (or LONGMEMEVAL_PATH env)");
44
- process.exit(2);
45
- }
46
- const k = Number(opts.k);
47
- if (!Number.isInteger(k) || k <= 0) {
48
- console.error(`--k must be a positive integer, got: ${opts.k}`);
49
- process.exit(2);
50
- }
51
- let limit: number | undefined;
52
- if (opts.limit !== undefined) {
53
- limit = Number(opts.limit);
54
- if (!Number.isInteger(limit) || limit <= 0) {
55
- console.error(`--limit must be a positive integer, got: ${opts.limit}`);
56
- process.exit(2);
57
- }
58
- }
59
- let perType: number | undefined;
60
- if (opts.stratify !== undefined) {
61
- perType = Number(opts.stratify);
62
- if (!Number.isInteger(perType) || perType <= 0) {
63
- console.error(`--stratify must be a positive integer, got: ${opts.stratify}`);
64
- process.exit(2);
65
- }
66
- }
67
- const adapterNames = opts.adapters.split(",").map((s) => s.trim()).filter(Boolean);
68
- for (const a of adapterNames) {
69
- if (!ADAPTERS[a]) {
70
- console.error(`unknown adapter: ${a}. options: ${Object.keys(ADAPTERS).join(",")}`);
71
- process.exit(2);
72
- }
73
- }
74
- let questions = loadLongMemEval(resolve(opts.data), limit);
75
- if (perType) questions = stratifySample(questions, perType);
76
- console.log(
77
- `loaded ${questions.length} questions, adapters: ${adapterNames.join(",")}, k=${k}`,
78
- );
79
-
80
- const outDir = resolve(opts.out);
81
- mkdirSync(outDir, { recursive: true });
82
- const ndjsonPath = `${outDir}/scores.ndjson`;
83
- if (existsSync(ndjsonPath)) writeFileSync(ndjsonPath, "");
84
- mkdirSync(dirname(ndjsonPath), { recursive: true });
85
-
86
- const rows: ScoreRow[] = [];
87
- for (const adapterName of adapterNames) {
88
- const adapter = ADAPTERS[adapterName];
89
- console.log(`\n== ${adapter.name} ==`);
90
- for (const q of questions) {
91
- const t0 = performance.now();
92
- const state = await adapter.init(q.haystack);
93
- try {
94
- const ranked = await adapter.query(q.question, state, k);
95
- const latencyMs = performance.now() - t0;
96
- const row = scoreQuestion(q, ranked, k, adapter.name, latencyMs);
97
- rows.push(row);
98
- appendFileSync(ndjsonPath, JSON.stringify(row) + "\n");
99
- const mark = row.hit ? "+" : "-";
100
- console.log(
101
- ` ${mark} ${q.id} [${q.type}] R@${k}=${row.recallAtK.toFixed(2)} (${Math.round(latencyMs)}ms)`,
102
- );
103
- } finally {
104
- if (adapter.teardown) await adapter.teardown(state);
105
- }
106
- }
107
- }
108
-
109
- const agg = aggregate(rows);
110
- const summaryPath = `${outDir}/summary.json`;
111
- writeFileSync(summaryPath, JSON.stringify(agg, null, 2));
112
-
113
- console.log("\n=== Summary ===");
114
- for (const [adapter, stats] of Object.entries(agg.byAdapter)) {
115
- console.log(
116
- ` ${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`,
117
- );
118
- }
119
- console.log(`\nwrote ${ndjsonPath}`);
120
- console.log(`wrote ${summaryPath}`);
121
- }
122
-
123
- main().catch((err) => {
124
- console.error(err);
125
- process.exit(1);
126
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/runner/score.ts DELETED
@@ -1,78 +0,0 @@
1
- import type { Question, RankedDoc, ScoreRow } from "./types.js";
2
-
3
- export function scoreQuestion(
4
- q: Question,
5
- ranked: RankedDoc[],
6
- k: number,
7
- adapter: string,
8
- latencyMs: number,
9
- ): ScoreRow {
10
- const topK = ranked.slice(0, k).map((r) => r.sessionId);
11
- const gold = new Set(q.goldSessionIds);
12
- const hits = topK.filter((id) => gold.has(id)).length;
13
- const precisionAtK = k > 0 ? hits / k : 0;
14
- const recallAtK = gold.size === 0 ? 0 : hits / gold.size;
15
- const hit = hits > 0;
16
- let topGoldRank: number | null = null;
17
- for (let i = 0; i < ranked.length; i++) {
18
- if (gold.has(ranked[i].sessionId)) {
19
- topGoldRank = i + 1;
20
- break;
21
- }
22
- }
23
- return {
24
- questionId: q.id,
25
- questionType: q.type,
26
- adapter,
27
- k,
28
- precisionAtK,
29
- recallAtK,
30
- hit,
31
- topGoldRank,
32
- latencyMs,
33
- };
34
- }
35
-
36
- export function aggregate(rows: ScoreRow[]): {
37
- byAdapter: Record<string, { p: number; r: number; hit: number; n: number; latencyP50: number }>;
38
- byType: Record<string, Record<string, { p: number; r: number; hit: number; n: number }>>;
39
- } {
40
- const byAdapter: Record<
41
- string,
42
- { p: number; r: number; hit: number; n: number; latencyP50: number }
43
- > = {};
44
- const latencies: Record<string, number[]> = {};
45
- for (const r of rows) {
46
- const a = (byAdapter[r.adapter] ??= { p: 0, r: 0, hit: 0, n: 0, latencyP50: 0 });
47
- a.p += r.precisionAtK;
48
- a.r += r.recallAtK;
49
- a.hit += r.hit ? 1 : 0;
50
- a.n += 1;
51
- (latencies[r.adapter] ??= []).push(r.latencyMs);
52
- }
53
- for (const adapter of Object.keys(byAdapter)) {
54
- const a = byAdapter[adapter];
55
- a.p = a.p / a.n;
56
- a.r = a.r / a.n;
57
- const sorted = latencies[adapter].slice().sort((x, y) => x - y);
58
- a.latencyP50 = sorted[Math.floor(sorted.length / 2)] ?? 0;
59
- }
60
- const byType: Record<string, Record<string, { p: number; r: number; hit: number; n: number }>> =
61
- {};
62
- for (const r of rows) {
63
- const t = (byType[r.questionType] ??= {});
64
- const a = (t[r.adapter] ??= { p: 0, r: 0, hit: 0, n: 0 });
65
- a.p += r.precisionAtK;
66
- a.r += r.recallAtK;
67
- a.hit += r.hit ? 1 : 0;
68
- a.n += 1;
69
- }
70
- for (const t of Object.keys(byType)) {
71
- for (const adapter of Object.keys(byType[t])) {
72
- const a = byType[t][adapter];
73
- a.p = a.p / a.n;
74
- a.r = a.r / a.n;
75
- }
76
- }
77
- return { byAdapter, byType };
78
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/runner/types.ts DELETED
@@ -1,38 +0,0 @@
1
- export interface Session {
2
- id: string;
3
- timestamp?: string;
4
- content: string;
5
- }
6
-
7
- export interface Question {
8
- id: string;
9
- type: string;
10
- question: string;
11
- answer?: string;
12
- goldSessionIds: string[];
13
- haystack: Session[];
14
- }
15
-
16
- export interface RankedDoc {
17
- sessionId: string;
18
- score: number;
19
- }
20
-
21
- export interface Adapter<State = unknown> {
22
- name: string;
23
- init(sessions: Session[], config?: Record<string, unknown>): Promise<State>;
24
- query(q: string, state: State, k: number): Promise<RankedDoc[]>;
25
- teardown?(state: State): Promise<void>;
26
- }
27
-
28
- export interface ScoreRow {
29
- questionId: string;
30
- questionType: string;
31
- adapter: string;
32
- k: number;
33
- precisionAtK: number;
34
- recallAtK: number;
35
- hit: boolean;
36
- topGoldRank: number | null;
37
- latencyMs: number;
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eval/scripts/sandbox.sh DELETED
@@ -1,117 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Boot a sandboxed agentmemory + iii-engine on alt ports with a clean data dir,
3
- # so eval runs aren't polluted by (and don't pollute) your real ~/.agentmemory.
4
- # Source it: `source eval/scripts/sandbox.sh` then run eval scripts;
5
- # the sandbox is torn down on EXIT.
6
-
7
- set -euo pipefail
8
-
9
- SANDBOX_ROOT="${SANDBOX_ROOT:-/tmp/agentmemory-eval-sandbox}"
10
- SANDBOX_PORT="${SANDBOX_PORT:-3411}"
11
- SANDBOX_STREAM_PORT="${SANDBOX_STREAM_PORT:-3412}"
12
-
13
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
14
-
15
- if ! command -v iii >/dev/null 2>&1; then
16
- echo "iii binary not on PATH. Install pinned version:"
17
- 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"
18
- exit 1
19
- fi
20
-
21
- iii_ver=$(iii --version 2>&1 | head -1)
22
- if [[ "$iii_ver" != "0.11.2" ]]; then
23
- echo "warning: iii version on PATH is $iii_ver; agentmemory pins 0.11.2"
24
- fi
25
-
26
- if [[ ! -f "$REPO_ROOT/dist/index.mjs" ]]; then
27
- echo "dist/ missing. Run: npm run build" >&2
28
- exit 1
29
- fi
30
-
31
- if [[ -z "${SANDBOX_ROOT:-}" || "$SANDBOX_ROOT" == "/" || "$SANDBOX_ROOT" != /tmp/* ]]; then
32
- echo "refusing to wipe SANDBOX_ROOT='$SANDBOX_ROOT' — must be non-empty and under /tmp/" >&2
33
- exit 1
34
- fi
35
- rm -rf "$SANDBOX_ROOT"
36
- mkdir -p "$SANDBOX_ROOT/data" "$SANDBOX_ROOT/.agentmemory"
37
-
38
- cat > "$SANDBOX_ROOT/iii-config.yaml" <<EOF
39
- workers:
40
- - name: iii-http
41
- config:
42
- port: $SANDBOX_PORT
43
- host: 127.0.0.1
44
- default_timeout: 180000
45
- cors:
46
- allowed_origins: ["http://localhost:$SANDBOX_PORT", "http://127.0.0.1:$SANDBOX_PORT"]
47
- allowed_methods: [GET, POST, PUT, DELETE, OPTIONS]
48
- - name: iii-state
49
- config:
50
- adapter:
51
- name: kv
52
- config:
53
- store_method: file_based
54
- file_path: $SANDBOX_ROOT/data/state_store.db
55
- - name: iii-queue
56
- config:
57
- adapter:
58
- name: builtin
59
- - name: iii-pubsub
60
- config:
61
- adapter:
62
- name: local
63
- - name: iii-cron
64
- config:
65
- adapter:
66
- name: kv
67
- - name: iii-stream
68
- config:
69
- port: $SANDBOX_STREAM_PORT
70
- host: 127.0.0.1
71
- adapter:
72
- name: kv
73
- config:
74
- store_method: file_based
75
- file_path: $SANDBOX_ROOT/data/stream_store
76
- - name: iii-observability
77
- config:
78
- enabled: true
79
- service_name: agentmemory-eval
80
- exporter: memory
81
- sampling_ratio: 1.0
82
- metrics_enabled: true
83
- logs_enabled: false
84
- logs_console_output: false
85
- - name: iii-exec
86
- config:
87
- exec:
88
- - node $REPO_ROOT/dist/index.mjs
89
- EOF
90
-
91
- cd "$SANDBOX_ROOT"
92
- HOME="$SANDBOX_ROOT" iii --config "$SANDBOX_ROOT/iii-config.yaml" > "$SANDBOX_ROOT/iii.log" 2>&1 &
93
- SANDBOX_PID=$!
94
-
95
- cleanup() {
96
- echo "tearing down sandbox (pid $SANDBOX_PID)"
97
- kill "$SANDBOX_PID" 2>/dev/null || true
98
- sleep 1
99
- kill -9 "$SANDBOX_PID" 2>/dev/null || true
100
- }
101
- trap cleanup EXIT
102
-
103
- # wait for livez
104
- for i in $(seq 1 30); do
105
- if curl -sS --max-time 1 "http://localhost:$SANDBOX_PORT/agentmemory/livez" 2>/dev/null | grep -q '"status":"ok"'; then
106
- export AGENTMEMORY_BASE_URL="http://localhost:$SANDBOX_PORT"
107
- echo "sandbox ready: $AGENTMEMORY_BASE_URL"
108
- echo " state: $SANDBOX_ROOT/data/"
109
- echo " logs: $SANDBOX_ROOT/iii.log"
110
- return 0 2>/dev/null || exit 0
111
- fi
112
- sleep 1
113
- done
114
-
115
- echo "sandbox failed to come up within 30s. last log lines:" >&2
116
- tail -10 "$SANDBOX_ROOT/iii.log" >&2
117
- exit 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/filesystem-watcher/README.md DELETED
@@ -1,63 +0,0 @@
1
- # @agentmemory/fs-watcher
2
-
3
- Filesystem connector for agentmemory. Watches one or more directories and emits an observation to the running agentmemory server every time a file changes.
4
-
5
- Part of the data-source-connectors effort tracked in issue #62.
6
-
7
- ## Install
8
-
9
- ```bash
10
- npm install -g @agentmemory/fs-watcher
11
- ```
12
-
13
- Or run without installing:
14
-
15
- ```bash
16
- npx @agentmemory/fs-watcher ~/work/my-repo
17
- ```
18
-
19
- ## Usage
20
-
21
- ```bash
22
- # CLI args win over env.
23
- agentmemory-fs-watcher ~/work/my-repo ~/notes
24
-
25
- # Or set env once in your shell.
26
- export AGENTMEMORY_FS_WATCH_DIRS=~/work/my-repo,~/notes
27
- export AGENTMEMORY_URL=http://localhost:3111
28
- export AGENTMEMORY_SECRET=... # only if the server requires auth
29
- agentmemory-fs-watcher
30
- ```
31
-
32
- 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).
33
-
34
- Session id and project are required by the observe endpoint — set them via env, or the watcher generates a per-process `fs-watcher-<ts>-<rand>` session id and uses the first root's directory name as the project.
35
-
36
- Requires Node.js **>=20 LTS**. Recursive `fs.watch` needs Node 19.1.0+ on Linux; Node 20 is the minimum supported LTS line.
37
-
38
- ## Configuration
39
-
40
- | Variable | Default | Meaning |
41
- |---|---|---|
42
- | `AGENTMEMORY_FS_WATCH_DIRS` | — | Comma-separated list of directories to watch |
43
- | `AGENTMEMORY_FS_WATCH_IGNORE` | — | Comma-separated regex patterns to ignore (applied to relative paths) |
44
- | `AGENTMEMORY_FS_WATCH_ALLOW_BINARY` | `0` | `1` to include binary files in the preview read |
45
- | `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL |
46
- | `AGENTMEMORY_SECRET` | — | Bearer token, required if the server has `AGENTMEMORY_SECRET` set |
47
- | `AGENTMEMORY_PROJECT` | — | Optional project label attached to each observation |
48
- | `AGENTMEMORY_SESSION_ID` | — | Optional session id to attribute observations to |
49
-
50
- ## Defaults
51
-
52
- Ignored out of the box: `.git/`, `node_modules/`, `dist/`, `build/`, `.next/`, `.turbo/`, `coverage/`, `.DS_Store`, `*.log`, `*.lock`. Extend with `AGENTMEMORY_FS_WATCH_IGNORE`.
53
-
54
- 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.
55
-
56
- Writes are debounced 500 ms per path so a stream of saves from your editor becomes a single observation.
57
-
58
- ## Notes
59
-
60
- - Uses Node's built-in `fs.watch` with `{ recursive: true }`. Works natively on macOS, Linux, and Windows 10+. No native deps.
61
- - If `fs.watch` errors on a specific root (permission, platform quirk), the watcher logs and continues on the others.
62
- - The process must keep running. Use a process manager (`launchd`, `systemd`, `pm2`) to supervise it.
63
- - This connector is intentionally one-way: it writes observations and never reads the agentmemory store.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/filesystem-watcher/bin.mjs DELETED
@@ -1,28 +0,0 @@
1
- #!/usr/bin/env node
2
- import { FilesystemWatcher, configFromEnv } from "./watcher.mjs";
3
-
4
- const cliArgs = process.argv.slice(2);
5
- const envCfg = configFromEnv(process.env);
6
-
7
- const roots = cliArgs.length > 0 ? cliArgs : envCfg.roots;
8
- if (!roots || roots.length === 0) {
9
- process.stderr.write(
10
- "agentmemory-fs-watcher: no directories to watch.\n" +
11
- "Usage: agentmemory-fs-watcher <dir> [<dir>...]\n" +
12
- "Or set AGENTMEMORY_FS_WATCH_DIRS=path1,path2\n",
13
- );
14
- process.exit(2);
15
- }
16
-
17
- const watcher = new FilesystemWatcher({ ...envCfg, roots });
18
- watcher.start();
19
- process.stderr.write(
20
- `[fs-watcher] emitting to ${envCfg.baseUrl || "http://localhost:3111"}\n`,
21
- );
22
-
23
- const shutdown = () => {
24
- watcher.stop();
25
- process.exit(0);
26
- };
27
- process.on("SIGINT", shutdown);
28
- process.on("SIGTERM", shutdown);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/filesystem-watcher/package.json DELETED
@@ -1,22 +0,0 @@
1
- {
2
- "name": "@agentmemory/fs-watcher",
3
- "version": "0.1.0",
4
- "description": "Filesystem connector for agentmemory — emits observations on file changes.",
5
- "type": "module",
6
- "bin": {
7
- "agentmemory-fs-watcher": "./bin.mjs"
8
- },
9
- "main": "./watcher.mjs",
10
- "exports": {
11
- ".": "./watcher.mjs"
12
- },
13
- "files": ["watcher.mjs", "bin.mjs", "README.md"],
14
- "engines": { "node": ">=20" },
15
- "license": "Apache-2.0",
16
- "homepage": "https://github.com/rohitg00/agentmemory/tree/main/integrations/filesystem-watcher",
17
- "repository": {
18
- "type": "git",
19
- "url": "https://github.com/rohitg00/agentmemory.git",
20
- "directory": "integrations/filesystem-watcher"
21
- }
22
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/filesystem-watcher/watcher.mjs DELETED
@@ -1,317 +0,0 @@
1
- import { watch, promises as fsp, statSync } from "node:fs";
2
- import { resolve, relative, join, extname, sep, basename } from "node:path";
3
- import { randomBytes } from "node:crypto";
4
-
5
- const TEXT_EXTENSIONS = new Set([
6
- ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
7
- ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift",
8
- ".c", ".cc", ".cpp", ".h", ".hpp",
9
- ".md", ".mdx", ".txt", ".rst",
10
- ".json", ".yaml", ".yml", ".toml", ".ini", ".env",
11
- ".html", ".css", ".scss", ".vue", ".svelte",
12
- ".sh", ".bash", ".zsh", ".fish",
13
- ".sql", ".graphql", ".proto",
14
- ]);
15
-
16
- const DEFAULT_IGNORE = [
17
- /(?:^|\/)\.git(?:\/|$)/,
18
- /(?:^|\/)node_modules(?:\/|$)/,
19
- /(?:^|\/)dist(?:\/|$)/,
20
- /(?:^|\/)build(?:\/|$)/,
21
- /(?:^|\/)\.next(?:\/|$)/,
22
- /(?:^|\/)\.turbo(?:\/|$)/,
23
- /(?:^|\/)coverage(?:\/|$)/,
24
- /(?:^|\/)\.DS_Store$/,
25
- /\.log$/,
26
- /\.lock$/,
27
- ];
28
-
29
- const MAX_PREVIEW_BYTES = 4096;
30
- const DEBOUNCE_MS = 500;
31
- const REDACTED = "[REDACTED]";
32
- const PEM_BEGIN_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
33
- const PEM_END_RE = /-----END [A-Z ]*PRIVATE KEY-----/;
34
- const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
35
- const JWT_MIN_LEN = 100;
36
-
37
- function isDotEnvPath(path) {
38
- const name = basename(path).toLowerCase();
39
- return name === ".env" || name.startsWith(".env.");
40
- }
41
-
42
- function isSensitiveKey(key) {
43
- const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
44
- return [
45
- "apikey",
46
- "accesstoken",
47
- "accesskey",
48
- "authorization",
49
- "bearer",
50
- "clientsecret",
51
- "password",
52
- "passwd",
53
- "privatekey",
54
- "pwd",
55
- "secret",
56
- "token",
57
- ].some((needle) => normalized.includes(needle));
58
- }
59
-
60
- function redactJwtTokens(line) {
61
- return line.replace(JWT_RE, (match) => (match.length >= JWT_MIN_LEN ? REDACTED : match));
62
- }
63
-
64
- function redactSensitiveLine(line) {
65
- if (PEM_BEGIN_RE.test(line) || PEM_END_RE.test(line)) {
66
- return line;
67
- }
68
- const assignment = line.match(
69
- /^(\s*(?:export\s+)?["']?([A-Za-z_][A-Za-z0-9_.-]*)["']?\s*([=:])\s*)(.*)$/,
70
- );
71
- if (assignment && isSensitiveKey(assignment[2])) {
72
- const bearer = assignment[3] === ":" ? assignment[4].match(/^(Bearer\s+).+/i) : null;
73
- return `${assignment[1]}${bearer ? bearer[1] : ""}${REDACTED}`;
74
- }
75
- const bearerRedacted = line.replace(
76
- /\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/gi,
77
- `$1${REDACTED}`,
78
- );
79
- return redactJwtTokens(bearerRedacted);
80
- }
81
-
82
- function redactPemBlocks(preview) {
83
- const lines = preview.split("\n");
84
- const out = [];
85
- let inBlock = false;
86
- for (const line of lines) {
87
- if (!inBlock) {
88
- const beginMatch = line.match(PEM_BEGIN_RE);
89
- if (!beginMatch) {
90
- out.push(line);
91
- continue;
92
- }
93
- const beginIdx = beginMatch.index;
94
- const endMatch = line.match(PEM_END_RE);
95
- if (endMatch && endMatch.index > beginIdx) {
96
- const before = line.slice(0, beginIdx);
97
- const after = line.slice(endMatch.index + endMatch[0].length);
98
- out.push(`${before}${beginMatch[0]}${REDACTED}${endMatch[0]}${after}`);
99
- } else {
100
- out.push(`${line.slice(0, beginIdx)}${beginMatch[0]}`);
101
- out.push(REDACTED);
102
- inBlock = true;
103
- }
104
- } else {
105
- const endMatch = line.match(PEM_END_RE);
106
- if (endMatch) {
107
- out.push(`${endMatch[0]}${line.slice(endMatch.index + endMatch[0].length)}`);
108
- inBlock = false;
109
- }
110
- }
111
- }
112
- return out.join("\n");
113
- }
114
-
115
- function redactSensitivePreview(preview) {
116
- return redactPemBlocks(preview).split("\n").map(redactSensitiveLine).join("\n");
117
- }
118
-
119
- export class FilesystemWatcher {
120
- constructor(config = {}) {
121
- this.roots = (config.roots || []).map((r) => resolve(r));
122
- this.baseUrl = (config.baseUrl || "http://localhost:3111").replace(/\/+$/, "");
123
- this.secret = config.secret;
124
- this.project =
125
- config.project ||
126
- (this.roots[0] ? basename(this.roots[0]) : "filesystem-watcher");
127
- this.sessionId =
128
- config.sessionId ||
129
- `fs-watcher-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
130
- this.ignore = [...DEFAULT_IGNORE, ...(config.ignorePatterns || [])];
131
- this.allowBinary = Boolean(config.allowBinary);
132
- this.logger = config.logger || console;
133
- this.watchers = [];
134
- this.pendingByPath = new Map();
135
- }
136
-
137
- isIgnored(path) {
138
- return this.ignore.some((re) => re.test(path));
139
- }
140
-
141
- isTextFile(path) {
142
- if (this.allowBinary) return true;
143
- const ext = extname(path).toLowerCase();
144
- return TEXT_EXTENSIONS.has(ext) || isDotEnvPath(path);
145
- }
146
-
147
- async readPreview(path) {
148
- try {
149
- const fh = await fsp.open(path, "r");
150
- try {
151
- const buf = Buffer.alloc(MAX_PREVIEW_BYTES);
152
- const { bytesRead } = await fh.read(buf, 0, MAX_PREVIEW_BYTES, 0);
153
- return buf.slice(0, bytesRead).toString("utf-8");
154
- } finally {
155
- await fh.close();
156
- }
157
- } catch {
158
- return null;
159
- }
160
- }
161
-
162
- async emit(event) {
163
- const headers = { "content-type": "application/json" };
164
- if (this.secret) headers.authorization = `Bearer ${this.secret}`;
165
- try {
166
- const res = await fetch(`${this.baseUrl}/agentmemory/observe`, {
167
- method: "POST",
168
- headers,
169
- body: JSON.stringify(event),
170
- signal: AbortSignal.timeout(5000),
171
- });
172
- if (!res.ok) {
173
- this.logger.warn?.(
174
- `[fs-watcher] observe ${res.status}: ${await res.text().catch(() => "")}`,
175
- );
176
- }
177
- } catch (err) {
178
- this.logger.warn?.(`[fs-watcher] observe failed: ${err?.message || err}`);
179
- }
180
- }
181
-
182
- schedule(rootDir, relPath) {
183
- const key = join(rootDir, relPath);
184
- const existing = this.pendingByPath.get(key);
185
- if (existing) clearTimeout(existing.timer);
186
- const timer = setTimeout(() => {
187
- this.pendingByPath.delete(key);
188
- this.flush(rootDir, relPath).catch((err) =>
189
- this.logger.warn?.(`[fs-watcher] flush failed: ${err?.message || err}`),
190
- );
191
- }, DEBOUNCE_MS);
192
- this.pendingByPath.set(key, { timer });
193
- }
194
-
195
- async flush(rootDir, relPath) {
196
- const absPath = join(rootDir, relPath);
197
- if (this.isIgnored(relPath)) return;
198
- let exists = true;
199
- let size = 0;
200
- try {
201
- const st = statSync(absPath);
202
- if (!st.isFile()) return;
203
- size = st.size;
204
- } catch {
205
- exists = false;
206
- }
207
- const changeKind = exists ? "file_change" : "file_delete";
208
- let preview = null;
209
- if (exists && this.isTextFile(absPath)) {
210
- preview = await this.readPreview(absPath);
211
- if (preview !== null) preview = redactSensitivePreview(preview);
212
- }
213
- const truncated = exists && size > MAX_PREVIEW_BYTES;
214
- const payload = {
215
- hookType: "post_tool_use",
216
- sessionId: this.sessionId,
217
- project: this.project,
218
- cwd: rootDir,
219
- timestamp: new Date().toISOString(),
220
- data: {
221
- source: "filesystem-watcher",
222
- changeKind,
223
- files: [relPath],
224
- content: this.formatContent(relPath, changeKind, preview, {
225
- size,
226
- truncated,
227
- }),
228
- rootDir,
229
- absPath,
230
- size,
231
- truncated,
232
- },
233
- };
234
- await this.emit(payload);
235
- }
236
-
237
- formatContent(relPath, changeKind, preview, { size, truncated }) {
238
- if (changeKind === "file_delete") return `deleted: ${relPath}`;
239
- const head = `${relPath} (${size} bytes${truncated ? ", truncated" : ""})`;
240
- if (preview === null) return head;
241
- return `${head}\n\n${preview}`;
242
- }
243
-
244
- start() {
245
- if (this.roots.length === 0) {
246
- throw new Error("filesystem-watcher: at least one root directory is required");
247
- }
248
- const failures = [];
249
- for (const root of this.roots) {
250
- try {
251
- const handle = watch(
252
- root,
253
- { recursive: true, persistent: true },
254
- (_eventType, filename) => {
255
- if (!filename) return;
256
- const rel = filename.split(sep).join("/");
257
- if (this.isIgnored(rel)) return;
258
- this.schedule(root, rel);
259
- },
260
- );
261
- handle.on("error", (err) => {
262
- this.logger.warn?.(`[fs-watcher] watch error on ${root}: ${err?.message || err}`);
263
- });
264
- this.watchers.push(handle);
265
- this.logger.info?.(`[fs-watcher] watching ${root}`);
266
- } catch (err) {
267
- const msg = err?.message || String(err);
268
- failures.push(`${root}: ${msg}`);
269
- this.logger.error?.(`[fs-watcher] failed to watch ${root}: ${msg}`);
270
- }
271
- }
272
- if (this.watchers.length === 0) {
273
- throw new Error(
274
- `filesystem-watcher: could not watch any of the configured roots. ` +
275
- `If you are on Node 18 + Linux, recursive fs.watch requires Node >=19.1.0; upgrade to Node 20 LTS or newer. ` +
276
- `Failures: ${failures.join("; ")}`,
277
- );
278
- }
279
- }
280
-
281
- stop() {
282
- for (const w of this.watchers) {
283
- try {
284
- w.close();
285
- } catch {}
286
- }
287
- this.watchers = [];
288
- for (const { timer } of this.pendingByPath.values()) {
289
- clearTimeout(timer);
290
- }
291
- this.pendingByPath.clear();
292
- }
293
- }
294
-
295
- // Small helper used by tests and bin.mjs to parse env.
296
- export function configFromEnv(env = process.env) {
297
- const roots = (env.AGENTMEMORY_FS_WATCH_DIRS || "")
298
- .split(",")
299
- .map((s) => s.trim())
300
- .filter(Boolean);
301
- const extraIgnore = (env.AGENTMEMORY_FS_WATCH_IGNORE || "")
302
- .split(",")
303
- .map((s) => s.trim())
304
- .filter(Boolean)
305
- .map((s) => new RegExp(s));
306
- return {
307
- roots,
308
- baseUrl: env.AGENTMEMORY_URL,
309
- secret: env.AGENTMEMORY_SECRET,
310
- project: env.AGENTMEMORY_PROJECT || null,
311
- sessionId: env.AGENTMEMORY_SESSION_ID || null,
312
- ignorePatterns: extraIgnore,
313
- allowBinary: env.AGENTMEMORY_FS_WATCH_ALLOW_BINARY === "1",
314
- };
315
- }
316
-
317
- export { relative as _relativeForTests };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/openclaw/README.md DELETED
@@ -1,155 +0,0 @@
1
- <p align="center">
2
- <img src="../../assets/banner.png" alt="agentmemory" width="640" />
3
- </p>
4
-
5
- <h1 align="center">
6
- <img src="https://github.com/openclaw.png?size=80" alt="OpenClaw" width="28" height="28" align="center" />
7
- &nbsp;agentmemory for OpenClaw
8
- </h1>
9
-
10
- <p align="center">
11
- <strong>Your OpenClaw agents remember everything. No more re-explaining.</strong><br/>
12
- <sub>Persistent cross-session memory via <a href="https://github.com/rohitg00/agentmemory">agentmemory</a> — 95.2% retrieval accuracy on <a href="https://arxiv.org/abs/2410.10813">LongMemEval-S</a>.</sub>
13
- </p>
14
-
15
- <p align="center">
16
- <img src="https://img.shields.io/badge/MCP-43_tools-1f6feb?style=flat-square" alt="43 MCP tools" />
17
- <img src="https://img.shields.io/badge/Plugin-memory_slot-1f6feb?style=flat-square" alt="OpenClaw memory plugin" />
18
- <img src="https://img.shields.io/badge/R@5-95.2%25-00875f?style=flat-square" alt="95.2% R@5" />
19
- <img src="https://img.shields.io/badge/Self--hosted-yes-00875f?style=flat-square" alt="Self-hosted" />
20
- <img src="https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square" alt="Apache 2.0" />
21
- </p>
22
-
23
- ---
24
-
25
- ## Install it in 30 seconds
26
-
27
- **Paste this prompt into OpenClaw** and it does the whole setup for you:
28
-
29
- ```text
30
- Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111.
31
-
32
- For zero-code setup, add this MCP server so OpenClaw gets all 43 memory tools:
33
-
34
- {
35
- "mcpServers": {
36
- "agentmemory": {
37
- "command": "npx",
38
- "args": ["-y", "@agentmemory/mcp"]
39
- }
40
- }
41
- }
42
-
43
- For deeper memory integration, copy `integrations/openclaw` from the agentmemory repo to `~/.openclaw/extensions/agentmemory`, then enable it in `~/.openclaw/openclaw.json`:
44
-
45
- {
46
- "plugins": {
47
- "slots": {
48
- "memory": "agentmemory"
49
- },
50
- "entries": {
51
- "agentmemory": {
52
- "enabled": true,
53
- "config": {
54
- "base_url": "http://localhost:3111",
55
- "token_budget": 2000,
56
- "min_confidence": 0.5,
57
- "fallback_on_error": true,
58
- "timeout_ms": 5000
59
- }
60
- }
61
- }
62
- }
63
- }
64
-
65
- Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer.
66
- ```
67
-
68
- That's it. OpenClaw handles the rest.
69
-
70
- ## Option 1: MCP server (zero code)
71
-
72
- Start the agentmemory server in a separate terminal:
73
-
74
- ```bash
75
- npx @agentmemory/agentmemory
76
- ```
77
-
78
- Then add to your OpenClaw MCP config:
79
-
80
- ```json
81
- {
82
- "mcpServers": {
83
- "agentmemory": {
84
- "command": "npx",
85
- "args": ["-y", "@agentmemory/mcp"]
86
- }
87
- }
88
- }
89
- ```
90
-
91
- OpenClaw now has access to all 43 MCP tools including `memory_recall`, `memory_save`, `memory_smart_search`, `memory_timeline`, `memory_profile`, and more.
92
-
93
- ## Option 2: OpenClaw memory plugin (deeper integration)
94
-
95
- Copy this folder into OpenClaw's extension directory:
96
-
97
- ```bash
98
- mkdir -p ~/.openclaw/extensions
99
- cp -r integrations/openclaw ~/.openclaw/extensions/agentmemory
100
- ```
101
-
102
- Then enable it in `~/.openclaw/openclaw.json`:
103
-
104
- ```json
105
- {
106
- "plugins": {
107
- "slots": {
108
- "memory": "agentmemory"
109
- },
110
- "entries": {
111
- "agentmemory": {
112
- "enabled": true,
113
- "config": {
114
- "base_url": "http://localhost:3111",
115
- "token_budget": 2000,
116
- "min_confidence": 0.5,
117
- "fallback_on_error": true,
118
- "timeout_ms": 5000
119
- }
120
- }
121
- }
122
- }
123
- }
124
- ```
125
-
126
- What the plugin does:
127
-
128
- - claims the `plugins.slots.memory = "agentmemory"` slot via `api.registerMemoryCapability({ promptBuilder })` so OpenClaw recognises it as the active memory plugin
129
- - recalls relevant long-term memory before the agent starts (via the `before_agent_start` hook)
130
- - captures completed conversation turns after the agent finishes (via the `agent_end` hook)
131
- - shares the same backend with Claude Code, Codex CLI, Gemini CLI, Hermes, pi, and other agents
132
-
133
- ### Memory runtime (current scope)
134
-
135
- 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.
136
-
137
- ## Troubleshooting
138
-
139
- **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`.
140
-
141
- **`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.
142
-
143
- **Connection refused on port 3111** — the agentmemory server is not running. Start it with `npx @agentmemory/agentmemory`.
144
-
145
- **No memories returned** — open `http://localhost:3113` and verify observations are being captured.
146
-
147
- ## See also
148
-
149
- - [agentmemory main README](../../README.md)
150
- - [Hermes integration](../hermes/README.md)
151
- - [pi integration](../pi/README.md)
152
-
153
- ## License
154
-
155
- Apache-2.0 (same as agentmemory)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/openclaw/openclaw.plugin.json DELETED
@@ -1,27 +0,0 @@
1
- {
2
- "id": "agentmemory",
3
- "kind": "memory",
4
- "name": "agentmemory",
5
- "description": "Persistent cross-session memory for OpenClaw via agentmemory.",
6
- "version": "0.9.4",
7
- "configSchema": {
8
- "type": "object",
9
- "additionalProperties": false,
10
- "properties": {
11
- "enabled": { "type": "boolean" },
12
- "base_url": { "type": "string" },
13
- "token_budget": { "type": "number" },
14
- "min_confidence": { "type": "number" },
15
- "fallback_on_error": { "type": "boolean" },
16
- "timeout_ms": { "type": "number" }
17
- }
18
- },
19
- "uiHints": {
20
- "enabled": { "label": "Enabled" },
21
- "base_url": { "label": "Base URL", "help": "agentmemory REST server base URL" },
22
- "token_budget": { "label": "Token Budget", "help": "Approximate context budget to inject before the agent starts" },
23
- "min_confidence": { "label": "Min Confidence" },
24
- "fallback_on_error": { "label": "Fallback On Error" },
25
- "timeout_ms": { "label": "Timeout (ms)" }
26
- }
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/openclaw/package.json DELETED
@@ -1,10 +0,0 @@
1
- {
2
- "name": "agentmemory",
3
- "version": "0.9.4",
4
- "type": "module",
5
- "openclaw": {
6
- "extensions": [
7
- "./plugin.mjs"
8
- ]
9
- }
10
- }
 
 
 
 
 
 
 
 
 
 
 
integrations/openclaw/plugin.mjs DELETED
@@ -1,217 +0,0 @@
1
- /**
2
- * agentmemory plugin for OpenClaw
3
- *
4
- * Deeper integration than raw MCP:
5
- * - claims the plugins.slots.memory slot via api.registerMemoryCapability({ promptBuilder })
6
- * - recalls relevant memories before the agent starts (before_agent_start hook)
7
- * - captures completed conversation turns after the agent finishes (agent_end hook)
8
- *
9
- * Requires the agentmemory server on localhost:3111.
10
- * Start it with: npx @agentmemory/agentmemory
11
- */
12
-
13
- const DEFAULT_BASE_URL = "http://localhost:3111";
14
- const DEFAULT_TIMEOUT_MS = 5000;
15
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
16
-
17
- const configSchema = {
18
- type: "object",
19
- additionalProperties: false,
20
- properties: {
21
- enabled: { type: "boolean" },
22
- base_url: { type: "string" },
23
- token_budget: { type: "number" },
24
- min_confidence: { type: "number" },
25
- fallback_on_error: { type: "boolean" },
26
- timeout_ms: { type: "number" },
27
- },
28
- };
29
-
30
- function extractText(content) {
31
- if (typeof content === "string") return content;
32
- if (!Array.isArray(content)) return "";
33
- return content
34
- .flatMap((block) => {
35
- if (!block || typeof block !== "object") return [];
36
- if (block.type === "text" && typeof block.text === "string") return [block.text];
37
- return [];
38
- })
39
- .join("\n")
40
- .trim();
41
- }
42
-
43
- function lastAssistantText(messages) {
44
- for (const message of [...messages].reverse()) {
45
- if (!message || typeof message !== "object") continue;
46
- if (message.role !== "assistant") continue;
47
- const text = extractText(message.content);
48
- if (text) return text;
49
- }
50
- return "";
51
- }
52
-
53
- function latestUserText(messages) {
54
- for (const message of [...messages].reverse()) {
55
- if (!message || typeof message !== "object") continue;
56
- if (message.role !== "user") continue;
57
- const text = extractText(message.content);
58
- if (text) return text;
59
- }
60
- return "";
61
- }
62
-
63
- function formatResults(results) {
64
- if (!Array.isArray(results) || results.length === 0) return "";
65
- return results
66
- .slice(0, 5)
67
- .map((result, index) => {
68
- const obs = result?.observation ?? result ?? {};
69
- const title = (obs.title || `Memory ${index + 1}`).trim();
70
- const narrative = (obs.narrative || "").trim();
71
- const type = (obs.type || "memory").trim();
72
- return `- ${title} (${type})${narrative ? `: ${narrative}` : ""}`;
73
- })
74
- .join("\n");
75
- }
76
-
77
- function normalizedHostname(hostname) {
78
- return hostname.replace(/^\[|\]$/g, "").toLowerCase();
79
- }
80
-
81
- function usesPlaintextBearerAuth(baseUrl, secret) {
82
- if (!secret) return false;
83
- try {
84
- const parsed = new URL(baseUrl);
85
- return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
86
- } catch {
87
- return false;
88
- }
89
- }
90
-
91
- function plaintextBearerAuthMessage(baseUrl) {
92
- 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.`;
93
- }
94
-
95
- export function createPlaintextBearerAuthGuard(warn, env) {
96
- let warned = false;
97
- return function guardPlaintextBearerAuth(baseUrl, secret) {
98
- if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
99
- const message = plaintextBearerAuthMessage(baseUrl);
100
- if ((env || process.env).AGENTMEMORY_REQUIRE_HTTPS === "1") throw new Error(message);
101
- if (!warned) {
102
- warned = true;
103
- warn(message);
104
- }
105
- };
106
- }
107
-
108
- function createClient(cfg, api) {
109
- const baseUrl = String(cfg.base_url || DEFAULT_BASE_URL).replace(/\/+$/, "");
110
- const timeoutMs = Number(cfg.timeout_ms || DEFAULT_TIMEOUT_MS);
111
- const fallbackOnError = cfg.fallback_on_error !== false;
112
- const secret = process.env.AGENTMEMORY_SECRET;
113
- const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard(
114
- (message) => api.logger.warn?.(message),
115
- );
116
- if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") {
117
- guardPlaintextBearerAuth(baseUrl, secret);
118
- }
119
-
120
- async function postJson(path, payload) {
121
- guardPlaintextBearerAuth(baseUrl, secret);
122
- const headers = { "Content-Type": "application/json" };
123
- if (secret) headers.Authorization = `Bearer ${secret}`;
124
- try {
125
- const res = await fetch(`${baseUrl}${path}`, {
126
- method: "POST",
127
- headers,
128
- body: JSON.stringify(payload),
129
- signal: AbortSignal.timeout(timeoutMs),
130
- });
131
- if (!res.ok) {
132
- if (fallbackOnError) return null;
133
- const body = await res.text().catch(() => "");
134
- throw new Error(`agentmemory ${path} failed: ${res.status} ${body}`);
135
- }
136
- return await res.json();
137
- } catch (error) {
138
- if (!fallbackOnError) throw error;
139
- api.logger.warn?.(`agentmemory: ${String(error)}`);
140
- return null;
141
- }
142
- }
143
-
144
- return { postJson, baseUrl };
145
- }
146
-
147
- const plugin = {
148
- id: "agentmemory",
149
- name: "agentmemory",
150
- description: "Shared cross-session memory via the local agentmemory server.",
151
- configSchema,
152
- register(api) {
153
- const cfg = {
154
- enabled: api.pluginConfig?.enabled !== false,
155
- base_url: api.pluginConfig?.base_url || DEFAULT_BASE_URL,
156
- token_budget: api.pluginConfig?.token_budget || 2000,
157
- min_confidence: api.pluginConfig?.min_confidence || 0.5,
158
- fallback_on_error: api.pluginConfig?.fallback_on_error !== false,
159
- timeout_ms: api.pluginConfig?.timeout_ms || DEFAULT_TIMEOUT_MS,
160
- };
161
- const client = createClient(cfg, api);
162
-
163
- if (typeof api.registerMemoryCapability === "function") {
164
- api.registerMemoryCapability({
165
- // OpenClaw passes { availableTools: Set<string>, citationsMode? }. We
166
- // don't currently branch on tool availability, but accept the params
167
- // object so the signature matches MemoryPromptSectionBuilder exactly.
168
- promptBuilder: (_params) => [
169
- "Long-term memory provider: agentmemory (external REST service on " +
170
- client.baseUrl +
171
- ").",
172
- "agentmemory recalls relevant prior observations before each turn via the before_agent_start hook and captures completed turns via agent_end.",
173
- "Treat recalled context as background, not authoritative — prefer current workspace state and explicit user instructions when they conflict.",
174
- ],
175
- });
176
- }
177
-
178
- api.on("before_agent_start", async (event) => {
179
- if (!cfg.enabled) return;
180
- const prompt = typeof event?.prompt === "string" ? event.prompt.trim() : "";
181
- if (!prompt) return;
182
- const result = await client.postJson("/agentmemory/smart-search", {
183
- query: prompt,
184
- limit: 5,
185
- });
186
- const block = formatResults(result?.results || []);
187
- if (!block) return;
188
- return {
189
- prependContext: `Relevant long-term memory from agentmemory:\n${block}`,
190
- };
191
- });
192
-
193
- api.on("agent_end", async (event) => {
194
- if (!cfg.enabled || !event?.success || !Array.isArray(event.messages)) return;
195
- const userText = latestUserText(event.messages);
196
- const assistantText = lastAssistantText(event.messages);
197
- if (!userText || !assistantText) return;
198
- const sessionId =
199
- event.sessionId ||
200
- event.sessionKey ||
201
- event.runId ||
202
- `openclaw-${Date.now()}`;
203
- await client.postJson("/agentmemory/observe", {
204
- hookType: "post_tool_use",
205
- sessionId,
206
- timestamp: new Date().toISOString(),
207
- data: {
208
- tool_name: "conversation",
209
- tool_input: userText.slice(0, 1000),
210
- tool_output: assistantText.slice(0, 4000),
211
- },
212
- });
213
- });
214
- },
215
- };
216
-
217
- export default plugin;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/openclaw/plugin.yaml DELETED
@@ -1,27 +0,0 @@
1
- name: agentmemory
2
- version: 0.8.1
3
- description: "Persistent cross-session memory for OpenClaw via agentmemory. 95.2% retrieval accuracy on LongMemEval-S."
4
- author: "Rohit Ghumare"
5
- homepage: "https://github.com/rohitg00/agentmemory"
6
- license: Apache-2.0
7
-
8
- category: memory
9
- tags:
10
- - memory
11
- - persistence
12
- - mcp
13
- - context
14
-
15
- hooks:
16
- - on_session_start
17
- - on_pre_llm_call
18
- - on_post_tool_use
19
- - on_session_end
20
-
21
- config:
22
- enabled: true
23
- base_url: http://localhost:3111
24
- token_budget: 2000
25
- min_confidence: 0.5
26
- fallback_on_error: true
27
- timeout_ms: 5000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/pi/README.md DELETED
@@ -1,77 +0,0 @@
1
- <p align="center">
2
- <img src="../../assets/banner.png" alt="agentmemory" width="640" />
3
- </p>
4
-
5
- <h1 align="center">
6
- &nbsp;agentmemory for pi
7
- </h1>
8
-
9
- <p align="center">
10
- <strong>Your pi sessions remember everything. No more re-explaining.</strong><br/>
11
- <sub>Persistent cross-session memory via <a href="https://github.com/rohitg00/agentmemory">agentmemory</a> — shared with Claude Code, Codex CLI, Gemini CLI, Hermes, OpenClaw, and more.</sub>
12
- </p>
13
-
14
- ---
15
-
16
- ## Quick setup
17
-
18
- Start the agentmemory server in a separate terminal:
19
-
20
- ```bash
21
- npx @agentmemory/agentmemory
22
- ```
23
-
24
- Copy this folder into pi's global extensions directory:
25
-
26
- ```bash
27
- mkdir -p ~/.pi/agent/extensions/agentmemory
28
- cp integrations/pi/index.ts ~/.pi/agent/extensions/agentmemory/index.ts
29
- ```
30
-
31
- Then enable it in `~/.pi/agent/settings.json` if you prefer explicit loading:
32
-
33
- ```json
34
- {
35
- "extensions": ["~/.pi/agent/extensions/agentmemory"]
36
- }
37
- ```
38
-
39
- If you place it under `~/.pi/agent/extensions/agentmemory/`, pi will also auto-discover it and `/reload` can hot-reload it.
40
-
41
- ## What it adds
42
-
43
- - `memory_health` — confirm the shared memory server is reachable
44
- - `memory_search` — search prior decisions, bugs, workflows, and preferences
45
- - `memory_save` — write durable facts back to long-term memory
46
- - `/agentmemory-status` — check health from inside pi
47
- - `before_agent_start` recall — injects relevant memories into the prompt
48
- - `agent_end` capture — saves completed conversation turns back to agentmemory
49
-
50
- ## Environment variables
51
-
52
- | Variable | Default | Description |
53
- |---|---|---|
54
- | `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL |
55
- | `AGENTMEMORY_SECRET` | (none) | Bearer token for protected instances |
56
- | `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. |
57
-
58
- ## Smoke test
59
-
60
- Run pi and ask it to use the `memory_health` tool, or call the command directly:
61
-
62
- ```text
63
- /agentmemory-status
64
- ```
65
-
66
- You should see `agentmemory healthy` and a footer status like `🧠 agentmemory`.
67
-
68
- ## Notes
69
-
70
- - This extension uses pi's extension API, not MCP, so it can hook directly into the agent lifecycle.
71
- - One local agentmemory server can be shared across pi, pi2, Hermes, OpenClaw, Claude Code, Codex CLI, and Gemini CLI.
72
-
73
- ## See also
74
-
75
- - [agentmemory main README](../../README.md)
76
- - [Hermes integration](../hermes/README.md)
77
- - [OpenClaw integration](../openclaw/README.md)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/pi/index.ts DELETED
@@ -1,275 +0,0 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { Type } from "typebox";
3
- import path from "node:path";
4
- import crypto from "node:crypto";
5
- import { createPlaintextBearerAuthGuard } from "./security.js";
6
-
7
- type TextBlock = { type?: string; text?: string };
8
- type AssistantMessage = { role?: string; content?: unknown };
9
- type SmartSearchResult = {
10
- title?: string;
11
- narrative?: string;
12
- type?: string;
13
- combinedScore?: number;
14
- score?: number;
15
- observation?: {
16
- title?: string;
17
- narrative?: string;
18
- type?: string;
19
- };
20
- };
21
-
22
- type HealthResponse = {
23
- status?: string;
24
- service?: string;
25
- version?: string;
26
- health?: {
27
- status?: string;
28
- notes?: string[];
29
- };
30
- };
31
-
32
- const DEFAULT_URL = process.env.AGENTMEMORY_URL || "http://localhost:3111";
33
- const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard();
34
- const TOOL_GUIDANCE = [
35
- "agentmemory is available for cross-session memory.",
36
- "Use memory_search to recall prior decisions, preferences, bugs, and workflows.",
37
- "Use memory_save when you discover durable facts worth remembering beyond this session.",
38
- ].join(" ");
39
-
40
- function normalizeBaseUrl(url: string): string {
41
- return url.replace(/\/+$/, "");
42
- }
43
-
44
- function getText(content: unknown): string {
45
- if (typeof content === "string") return content;
46
- if (!Array.isArray(content)) return "";
47
- return content
48
- .flatMap((part) => {
49
- if (!part || typeof part !== "object") return [] as string[];
50
- const block = part as TextBlock;
51
- if (block.type === "text" && typeof block.text === "string") return [block.text];
52
- return [] as string[];
53
- })
54
- .join("\n")
55
- .trim();
56
- }
57
-
58
- function getLastAssistantText(messages: unknown[]): string {
59
- for (const msg of [...messages].reverse()) {
60
- if (!msg || typeof msg !== "object") continue;
61
- const assistant = msg as AssistantMessage;
62
- if (assistant.role !== "assistant") continue;
63
- const text = getText(assistant.content);
64
- if (text) return text;
65
- }
66
- return "";
67
- }
68
-
69
- function formatSearchResults(results: SmartSearchResult[]): string {
70
- if (!results.length) return "No relevant memories found.";
71
- return results
72
- .slice(0, 5)
73
- .map((result, index) => {
74
- const obs = result.observation ?? result;
75
- const title = obs.title?.trim() || `Memory ${index + 1}`;
76
- const narrative = obs.narrative?.trim() || "";
77
- const type = obs.type?.trim() || "memory";
78
- const score = result.combinedScore ?? result.score;
79
- const scoreText = typeof score === "number" ? ` [score=${score.toFixed(3)}]` : "";
80
- return `- ${title} (${type})${scoreText}${narrative ? `: ${narrative}` : ""}`;
81
- })
82
- .join("\n");
83
- }
84
-
85
- async function callAgentMemory<T>(
86
- pathname: string,
87
- options?: {
88
- method?: "GET" | "POST";
89
- body?: unknown;
90
- baseUrl?: string;
91
- },
92
- ): Promise<T | null> {
93
- const baseUrl = normalizeBaseUrl(options?.baseUrl || process.env.AGENTMEMORY_URL || DEFAULT_URL);
94
- const method = options?.method || "POST";
95
- const url = `${baseUrl}/agentmemory/${pathname.replace(/^\/+/, "")}`;
96
- const headers: Record<string, string> = {};
97
- const secret = process.env.AGENTMEMORY_SECRET;
98
- guardPlaintextBearerAuth(baseUrl, secret);
99
- if (options?.body !== undefined) headers["Content-Type"] = "application/json";
100
- if (secret) headers.Authorization = `Bearer ${secret}`;
101
-
102
- try {
103
- const response = await fetch(url, {
104
- method,
105
- headers,
106
- body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
107
- });
108
- if (!response.ok) return null;
109
- return (await response.json()) as T;
110
- } catch {
111
- return null;
112
- }
113
- }
114
-
115
- export default function agentmemoryExtension(pi: ExtensionAPI) {
116
- if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") {
117
- guardPlaintextBearerAuth(
118
- normalizeBaseUrl(process.env.AGENTMEMORY_URL || DEFAULT_URL),
119
- process.env.AGENTMEMORY_SECRET,
120
- );
121
- }
122
- let sessionId = `ephemeral-${crypto.randomUUID().slice(0, 8)}`;
123
- let currentProject = process.cwd();
124
- let lastPrompt = "";
125
- let lastHealthOk = false;
126
-
127
- async function getHealth() {
128
- return await callAgentMemory<HealthResponse>("health", { method: "GET" });
129
- }
130
-
131
- async function refreshStatus(ctx: { ui: { setStatus: (key: string, text: string) => void } }) {
132
- const health = await getHealth();
133
- lastHealthOk = !!health && (health.status === "healthy" || health.health?.status === "healthy");
134
- ctx.ui.setStatus("agentmemory", lastHealthOk ? "🧠 agentmemory" : "🧠 agentmemory off");
135
- }
136
-
137
- pi.registerCommand("agentmemory-status", {
138
- description: "Check local agentmemory server health",
139
- handler: async (_args, ctx) => {
140
- const health = await getHealth();
141
- if (!health) {
142
- ctx.ui.notify("agentmemory is unreachable at http://localhost:3111", "warning");
143
- return;
144
- }
145
- ctx.ui.notify(
146
- `agentmemory ${health.status || health.health?.status || "unknown"}${health.version ? ` v${health.version}` : ""}`,
147
- "info",
148
- );
149
- },
150
- });
151
-
152
- pi.registerTool({
153
- name: "memory_health",
154
- label: "Memory Health",
155
- description: "Check whether the local agentmemory server is reachable and healthy",
156
- parameters: Type.Object({}),
157
- async execute() {
158
- const health = await getHealth();
159
- if (!health) {
160
- return {
161
- content: [{ type: "text", text: "agentmemory is unreachable at http://localhost:3111" }],
162
- details: { ok: false },
163
- };
164
- }
165
- return {
166
- content: [
167
- {
168
- type: "text",
169
- text: `agentmemory status: ${health.status || health.health?.status || "unknown"}${health.version ? ` (v${health.version})` : ""}`,
170
- },
171
- ],
172
- details: health,
173
- };
174
- },
175
- });
176
-
177
- pi.registerTool({
178
- name: "memory_search",
179
- label: "Memory Search",
180
- description: "Search agentmemory for cross-session project memory, prior decisions, bugs, and user preferences",
181
- parameters: Type.Object({
182
- query: Type.String({ description: "What to search for in memory" }),
183
- limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 5, description: "Maximum results" })),
184
- }),
185
- async execute(_toolCallId, params) {
186
- const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
187
- body: { query: params.query, limit: params.limit ?? 5 },
188
- });
189
- const results = result?.results || [];
190
- return {
191
- content: [{ type: "text", text: formatSearchResults(results) }],
192
- details: { query: params.query, results },
193
- };
194
- },
195
- });
196
-
197
- pi.registerTool({
198
- name: "memory_save",
199
- label: "Memory Save",
200
- description: "Save a durable fact, convention, workflow, preference, or bug fix into agentmemory",
201
- parameters: Type.Object({
202
- content: Type.String({ description: "What should be remembered" }),
203
- type: Type.Optional(
204
- Type.String({
205
- description: "Memory type",
206
- default: "fact",
207
- }),
208
- ),
209
- }),
210
- async execute(_toolCallId, params) {
211
- const result = await callAgentMemory<Record<string, unknown>>("remember", {
212
- body: { content: params.content, type: params.type || "fact" },
213
- });
214
- if (!result) {
215
- return {
216
- content: [{ type: "text", text: "Failed to save memory to agentmemory." }],
217
- details: { ok: false },
218
- };
219
- }
220
- return {
221
- content: [{ type: "text", text: `Saved memory (${params.type || "fact"}): ${params.content}` }],
222
- details: result,
223
- };
224
- },
225
- });
226
-
227
- pi.on("session_start", async (_event, ctx) => {
228
- const sessionFile = ctx.sessionManager.getSessionFile();
229
- sessionId = sessionFile ? path.basename(sessionFile).replace(/\.[^.]+$/, "") : `ephemeral-${crypto.randomUUID().slice(0, 8)}`;
230
- currentProject = process.cwd();
231
- await refreshStatus(ctx);
232
- });
233
-
234
- pi.on("before_agent_start", async (event, ctx) => {
235
- currentProject = event.systemPromptOptions.cwd || process.cwd();
236
- lastPrompt = event.prompt?.trim() || "";
237
- if (!lastPrompt) return;
238
-
239
- const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
240
- body: { query: lastPrompt, limit: 5 },
241
- });
242
- const results = result?.results || [];
243
- const recallBlock = results.length
244
- ? [
245
- "Relevant long-term memory from agentmemory:",
246
- formatSearchResults(results),
247
- ].join("\n")
248
- : "";
249
-
250
- await refreshStatus(ctx);
251
- return {
252
- systemPrompt: [event.systemPrompt, TOOL_GUIDANCE, recallBlock].filter(Boolean).join("\n\n"),
253
- };
254
- });
255
-
256
- pi.on("agent_end", async (event) => {
257
- if (!lastHealthOk || !lastPrompt) return;
258
- const assistantText = getLastAssistantText(event.messages as unknown[]);
259
- if (!assistantText) return;
260
- void callAgentMemory("observe", {
261
- body: {
262
- hookType: "post_tool_use",
263
- sessionId,
264
- project: currentProject,
265
- cwd: currentProject,
266
- timestamp: new Date().toISOString(),
267
- data: {
268
- tool_name: "conversation",
269
- tool_input: lastPrompt.slice(0, 500),
270
- tool_output: assistantText.slice(0, 4000),
271
- },
272
- },
273
- });
274
- });
275
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
integrations/pi/package.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "name": "agentmemory-pi-extension",
3
- "private": true,
4
- "type": "module"
5
- }
 
 
 
 
 
 
integrations/pi/security.ts DELETED
@@ -1,35 +0,0 @@
1
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
2
-
3
- function normalizedHostname(hostname: string): string {
4
- return hostname.replace(/^\[|\]$/g, "").toLowerCase();
5
- }
6
-
7
- export function usesPlaintextBearerAuth(baseUrl: string, secret?: string): boolean {
8
- if (!secret) return false;
9
- try {
10
- const parsed = new URL(baseUrl);
11
- return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
12
- } catch {
13
- return false;
14
- }
15
- }
16
-
17
- export function plaintextBearerAuthMessage(baseUrl: string): string {
18
- 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.`;
19
- }
20
-
21
- export function createPlaintextBearerAuthGuard(
22
- warn: (message: string) => void = (message) => console.warn(message),
23
- env?: { AGENTMEMORY_REQUIRE_HTTPS?: string },
24
- ): (baseUrl: string, secret?: string) => void {
25
- let warned = false;
26
- return (baseUrl, secret) => {
27
- if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
28
- const message = plaintextBearerAuthMessage(baseUrl);
29
- if ((env || process.env).AGENTMEMORY_REQUIRE_HTTPS === "1") throw new Error(message);
30
- if (!warned) {
31
- warned = true;
32
- warn(message);
33
- }
34
- };
35
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/.claude-plugin/plugin.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "name": "agentmemory",
3
- "version": "0.9.27",
4
- "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.",
5
- "author": {
6
- "name": "Rohit Ghumare",
7
- "url": "https://github.com/rohitg00"
8
- },
9
- "license": "Apache-2.0",
10
- "homepage": "https://github.com/rohitg00/agentmemory",
11
- "repository": "https://github.com/rohitg00/agentmemory",
12
- "skills": ["./skills/"]
13
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/.codex-plugin/plugin.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "name": "agentmemory",
3
- "version": "0.9.27",
4
- "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.",
5
- "author": {
6
- "name": "Rohit Ghumare",
7
- "url": "https://github.com/rohitg00"
8
- },
9
- "license": "Apache-2.0",
10
- "homepage": "https://github.com/rohitg00/agentmemory",
11
- "repository": "https://github.com/rohitg00/agentmemory",
12
- "skills": "./skills/",
13
- "mcpServers": "./.mcp.json",
14
- "hooks": "./hooks/hooks.codex.json"
15
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/.mcp.copilot.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "mcpServers": {
3
- "agentmemory": {
4
- "type": "local",
5
- "command": "npx",
6
- "args": ["-y", "@agentmemory/mcp"],
7
- "env": {
8
- "AGENTMEMORY_URL": "${AGENTMEMORY_URL:-http://localhost:3111}",
9
- "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET:-}",
10
- "AGENTMEMORY_TOOLS": "${AGENTMEMORY_TOOLS:-all}"
11
- },
12
- "tools": ["*"]
13
- }
14
- }
15
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/.mcp.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "mcpServers": {
3
- "agentmemory": {
4
- "command": "npx",
5
- "args": ["-y", "@agentmemory/mcp"],
6
- "env": {
7
- "AGENTMEMORY_URL": "${AGENTMEMORY_URL:-http://localhost:3111}",
8
- "AGENTMEMORY_SECRET": "${AGENTMEMORY_SECRET:-}",
9
- "AGENTMEMORY_TOOLS": "${AGENTMEMORY_TOOLS:-all}"
10
- }
11
- }
12
- }
13
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/hooks/hooks.codex.json DELETED
@@ -1,67 +0,0 @@
1
- {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\"",
9
- "statusMessage": "agentmemory: loading session context"
10
- }
11
- ]
12
- }
13
- ],
14
- "UserPromptSubmit": [
15
- {
16
- "hooks": [
17
- {
18
- "type": "command",
19
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/prompt-submit.mjs\"",
20
- "statusMessage": "agentmemory: recalling relevant memories"
21
- }
22
- ]
23
- }
24
- ],
25
- "PreToolUse": [
26
- {
27
- "matcher": "Edit|Write|Read|Glob|Grep",
28
- "hooks": [
29
- {
30
- "type": "command",
31
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\""
32
- }
33
- ]
34
- }
35
- ],
36
- "PostToolUse": [
37
- {
38
- "hooks": [
39
- {
40
- "type": "command",
41
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.mjs\""
42
- }
43
- ]
44
- }
45
- ],
46
- "PreCompact": [
47
- {
48
- "hooks": [
49
- {
50
- "type": "command",
51
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-compact.mjs\""
52
- }
53
- ]
54
- }
55
- ],
56
- "Stop": [
57
- {
58
- "hooks": [
59
- {
60
- "type": "command",
61
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.mjs\""
62
- }
63
- ]
64
- }
65
- ]
66
- }
67
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/hooks/hooks.copilot.json DELETED
@@ -1,72 +0,0 @@
1
- {
2
- "version": 1,
3
- "hooks": {
4
- "sessionStart": [
5
- {
6
- "type": "command",
7
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/session-start.mjs"
8
- }
9
- ],
10
- "userPromptSubmitted": [
11
- {
12
- "type": "command",
13
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/prompt-submit.mjs"
14
- }
15
- ],
16
- "preToolUse": [
17
- {
18
- "type": "command",
19
- "matcher": "edit|write|create|read|view|glob|grep",
20
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/pre-tool-use.mjs"
21
- }
22
- ],
23
- "postToolUse": [
24
- {
25
- "type": "command",
26
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-use.mjs"
27
- }
28
- ],
29
- "postToolUseFailure": [
30
- {
31
- "type": "command",
32
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/post-tool-failure.mjs"
33
- }
34
- ],
35
- "preCompact": [
36
- {
37
- "type": "command",
38
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/pre-compact.mjs"
39
- }
40
- ],
41
- "agentStop": [
42
- {
43
- "type": "command",
44
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/stop.mjs"
45
- }
46
- ],
47
- "sessionEnd": [
48
- {
49
- "type": "command",
50
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/session-end.mjs"
51
- }
52
- ],
53
- "subagentStart": [
54
- {
55
- "type": "command",
56
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/subagent-start.mjs"
57
- }
58
- ],
59
- "subagentStop": [
60
- {
61
- "type": "command",
62
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/subagent-stop.mjs"
63
- }
64
- ],
65
- "notification": [
66
- {
67
- "type": "command",
68
- "command": "node ${COPILOT_PLUGIN_ROOT}/scripts/notification.mjs"
69
- }
70
- ]
71
- }
72
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/hooks/hooks.json DELETED
@@ -1,125 +0,0 @@
1
- {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-start.mjs\""
9
- }
10
- ]
11
- }
12
- ],
13
- "UserPromptSubmit": [
14
- {
15
- "hooks": [
16
- {
17
- "type": "command",
18
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/prompt-submit.mjs\""
19
- }
20
- ]
21
- }
22
- ],
23
- "PreToolUse": [
24
- {
25
- "matcher": "Edit|Write|Read|Glob|Grep",
26
- "hooks": [
27
- {
28
- "type": "command",
29
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-tool-use.mjs\""
30
- }
31
- ]
32
- }
33
- ],
34
- "PostToolUse": [
35
- {
36
- "hooks": [
37
- {
38
- "type": "command",
39
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-use.mjs\""
40
- }
41
- ]
42
- }
43
- ],
44
- "PostToolUseFailure": [
45
- {
46
- "hooks": [
47
- {
48
- "type": "command",
49
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-failure.mjs\""
50
- }
51
- ]
52
- }
53
- ],
54
- "PreCompact": [
55
- {
56
- "hooks": [
57
- {
58
- "type": "command",
59
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/pre-compact.mjs\""
60
- }
61
- ]
62
- }
63
- ],
64
- "SubagentStart": [
65
- {
66
- "hooks": [
67
- {
68
- "type": "command",
69
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-start.mjs\""
70
- }
71
- ]
72
- }
73
- ],
74
- "SubagentStop": [
75
- {
76
- "hooks": [
77
- {
78
- "type": "command",
79
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent-stop.mjs\""
80
- }
81
- ]
82
- }
83
- ],
84
- "Notification": [
85
- {
86
- "hooks": [
87
- {
88
- "type": "command",
89
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/notification.mjs\""
90
- }
91
- ]
92
- }
93
- ],
94
- "TaskCompleted": [
95
- {
96
- "hooks": [
97
- {
98
- "type": "command",
99
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/task-completed.mjs\""
100
- }
101
- ]
102
- }
103
- ],
104
- "Stop": [
105
- {
106
- "hooks": [
107
- {
108
- "type": "command",
109
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop.mjs\""
110
- }
111
- ]
112
- }
113
- ],
114
- "SessionEnd": [
115
- {
116
- "hooks": [
117
- {
118
- "type": "command",
119
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-end.mjs\""
120
- }
121
- ]
122
- }
123
- ]
124
- }
125
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/opencode/README.md DELETED
@@ -1,229 +0,0 @@
1
- <h1 align="center">
2
- <img src="https://github.com/opencode-ai.png?size=80" alt="OpenCode" width="28" height="28" align="center" />
3
- &nbsp;agentmemory for OpenCode
4
- </h1>
5
-
6
- <p align="center">
7
- <strong>Your OpenCode agents remember everything. No more re-explaining.</strong><br/>
8
- <sub>Persistent cross-session memory via <a href="https://github.com/rohitg00/agentmemory">agentmemory</a> — 95.2% retrieval accuracy on <a href="https://arxiv.org/abs/2410.10813">LongMemEval-S</a>.</sub>
9
- </p>
10
-
11
- <p align="center">
12
- <img src="https://img.shields.io/badge/MCP-53_tools-1f6feb?style=flat-square" alt="53 MCP tools" />
13
- <img src="https://img.shields.io/badge/Plugin-22_hooks-1f6feb?style=flat-square" alt="22 hooks" />
14
- <img src="https://img.shields.io/badge/Commands-2_slash-1f6feb?style=flat-square" alt="2 slash commands" />
15
- <img src="https://img.shields.io/badge/R@5-95.2%25-00875f?style=flat-square" alt="95.2% R@5" />
16
- </p>
17
-
18
- ---
19
-
20
- ## Quick start
21
-
22
- ### 1. Start the agentmemory server
23
-
24
- ```bash
25
- npx @agentmemory/agentmemory
26
- ```
27
-
28
- The server starts on `http://localhost:3111`.
29
-
30
- ### 2. Configure the MCP server
31
-
32
- Add to `~/.config/opencode/opencode.json` or your project's `.opencode/opencode.json`:
33
-
34
- ```json
35
- {
36
- "mcp": {
37
- "agentmemory": {
38
- "type": "local",
39
- "command": ["npx", "-y", "@agentmemory/mcp"],
40
- "enabled": true
41
- }
42
- }
43
- }
44
- ```
45
-
46
- ### 3. Install the plugin
47
-
48
- Add to `~/.config/opencode/opencode.json`:
49
-
50
- ```json
51
- {
52
- "plugin": ["./plugins/agentmemory-capture.ts"]
53
- }
54
- ```
55
-
56
- Copy the plugin file from this repo:
57
-
58
- ```bash
59
- mkdir -p ~/.config/opencode/plugins
60
- cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/
61
- ```
62
-
63
- ### 4. Add the slash commands
64
-
65
- Copy the commands into your project or global `.opencode/commands/` directory:
66
-
67
- ```bash
68
- mkdir -p ~/.config/opencode/commands
69
- cp plugin/opencode/commands/recall.md ~/.config/opencode/commands/
70
- cp plugin/opencode/commands/remember.md ~/.config/opencode/commands/
71
- ```
72
-
73
- Restart OpenCode or open a new session. The plugin auto-captures everything.
74
-
75
- ## What gets captured
76
-
77
- ### Session lifecycle
78
-
79
- | Event | Hook | agentmemory API |
80
- |---|---|---|
81
- | Session start | `session.created` | POST /session/start |
82
- | Idle → summarize | `session.idle` + `session.status` (idle) | POST /summarize |
83
- | Status transitions | `session.status` (idle/busy/retry) | POST /observe |
84
- | Compaction | `session.compacted` | POST /summarize + POST /observe |
85
- | Metadata updates | `session.updated` | POST /observe |
86
- | Code change tracking | `session.diff` | POST /observe |
87
- | Session delete | `session.deleted` | POST /session/end |
88
- | Session error | `session.error` | POST /observe |
89
-
90
- ### Messages & prompts
91
-
92
- | Event | Hook | agentmemory API |
93
- |---|---|---|
94
- | User prompt (rich) | `chat.message` | POST /observe |
95
- | User prompt metadata | `message.updated` (user) | POST /observe |
96
- | Assistant response | `message.updated` (assistant) | POST /observe |
97
- | Message removed (undo) | `message.removed` | POST /observe |
98
-
99
- ### Parts & steps
100
-
101
- | Event | Hook | agentmemory API |
102
- |---|---|---|
103
- | Subagent start | `message.part.updated` (subtask) | POST /observe |
104
- | Tool completed | `message.part.updated` (tool completed) | POST /observe |
105
- | Tool error | `message.part.updated` (tool error) | POST /observe |
106
- | Step finish (cost/tokens) | `message.part.updated` (step-finish) | POST /observe |
107
- | Reasoning trace | `message.part.updated` (reasoning) | POST /observe |
108
- | Patch applied | `message.part.updated` (patch) | POST /observe |
109
- | Auto/manual compaction | `message.part.updated` (compaction) | POST /observe |
110
- | Agent selection | `message.part.updated` (agent) | POST /observe |
111
- | API retry | `message.part.updated` (retry) | POST /observe |
112
-
113
- ### File enrichment pipeline
114
-
115
- | Event | Hook | agentmemory API |
116
- |---|---|---|
117
- | File tool params | `tool.execute.before` → stash paths | — |
118
- | File edited | `file.edited` → stash paths | — |
119
- | File part attached | `message.part.updated` (file) → stash paths | — |
120
- | Enrichment inject | `experimental.chat.system.transform` | POST /enrich → `output.system[]` |
121
- | Memory context inject | `experimental.chat.system.transform` | POST /context → `output.system[]` |
122
-
123
- ### Permissions
124
-
125
- | Event | Hook | agentmemory API |
126
- |---|---|---|
127
- | Permission prompt | `permission.updated` | POST /observe |
128
- | Permission reply | `permission.replied` | POST /observe |
129
-
130
- ### Tasks & commands
131
-
132
- | Event | Hook | agentmemory API |
133
- |---|---|---|
134
- | Task tracking (w/ priority) | `todo.updated` | POST /observe |
135
- | Command executed | `command.executed` | POST /observe |
136
-
137
- ### Model & config
138
-
139
- | Event | Hook | agentmemory API |
140
- |---|---|---|
141
- | LLM parameters | `chat.params` | POST /observe |
142
- | Config loaded | `config` | POST /observe |
143
- | Compaction (WIP) | `experimental.session.compacting` | POST /context → `output.context[]` |
144
-
145
- ### File enrichment + memory injection (two-layer pipeline)
146
-
147
- `experimental.chat.system.transform` fires before every LLM call and injects two layers of context:
148
-
149
- 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.
150
-
151
- 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.
152
-
153
- ```text
154
- System prompt = [OpenCode instructions] + [memory context] + [file enrichment] + [user message]
155
- ^ ^
156
- first turn only every file-touching turn
157
- ```
158
-
159
- **Differences from Claude's PreToolUse:**
160
-
161
- | Dimension | Claude (PreToolUse) | OpenCode (two-hop pipeline) |
162
- |---|---|---|
163
- | Injection mechanism | stdout → context window | `output.system[]` → system prompt |
164
- | Timing | Same turn (parallel with tool) | Next turn (before next LLM call) |
165
- | File set | Per-tool (immediate) | Batched (all files since last enrichment) |
166
- | Coverage | Edit/Write/Read/Glob/Grep only | Edit/Write/Read/Glob/Grep only |
167
- | What gets injected | `<agentmemory-file-context>` + bug memories | Identical `/enrich` response |
168
-
169
- ## MEMORY.md vs AGENTS.md: how context flows
170
-
171
- Claude Code and OpenCode take fundamentally different approaches to injecting memory context into the agent's system prompt.
172
-
173
- ### Claude Code: file-backed bridge (two-hop)
174
-
175
- ```
176
- agentmemory ──write──▶ MEMORY.md ──read──▶ Claude system prompt
177
- ```
178
-
179
- - The `claude-bridge/sync` endpoint serializes agentmemory observations into a `MEMORY.md` file in the project root
180
- - Claude Code reads `MEMORY.md` on session start and prepends it to the system prompt
181
- - **Sync is periodic** — sessions only get fresh context when the bridge last ran (session end, pre-compact)
182
- - **Coupling**: memory data lives in a git-trackable file, visible to CI, team members, and other tools
183
-
184
- ### OpenCode: direct injection (one-hop)
185
-
186
- ```
187
- agentmemory ──push──▶ OpenCode system prompt
188
- ```
189
-
190
- - `experimental.chat.system.transform` calls `/context` at runtime and pushes the response directly into `output.system[]`
191
- - **Always current** — context is fetched at session start (once) and before file-touching turns (per-batch)
192
- - **No file intermediary** — no stale copies, no merge conflicts, no disk I/O
193
- - `AGENTS.md` is a static instruction file for project conventions, coding standards, and tool guidance — agentmemory does not read or write it
194
-
195
- ### Tradeoffs
196
-
197
- | Dimension | Claude (MEMORY.md bridge) | OpenCode (direct injection) |
198
- |---|---|---|
199
- | Freshness | Stale between syncs | Always current (fetched at call time) |
200
- | Visibility | Human-readable file in repo | In-memory injection only |
201
- | Simplicity | Two moving parts (bridge + file) | One step (API → system prompt) |
202
- | Team sharing | File is git-trackable, CI-friendly | Memory shared via agentmemory server API |
203
- | Integration | Any tool can read MEMORY.md | Requires OpenCode plugin SDK |
204
-
205
- ### Why OpenCode goes direct
206
-
207
- 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.
208
-
209
- ## Slash commands
210
-
211
- - `/recall <query>` — Search past observations and lessons
212
- - `/remember <text>` — Save an insight to long-term memory
213
-
214
- ## Session instruction injection
215
-
216
- 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.
217
-
218
- ## What's not covered (vs Claude Code plugin)
219
-
220
- | Claude feature | Reason |
221
- |---|---|
222
- | SubagentStop | OpenCode's `SubtaskPart` type has no completion/result fields; subtask lifecycle ends are not exposed as distinct events in the OpenCode SDK |
223
- | TaskCompleted | No team/teammate concept in OpenCode; `todo.updated` captures task state changes as a partial equivalent |
224
- | 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 |
225
- | Skills (remember/recall/forget/session-history) | Covered by injected system instructions via `experimental.chat.system.transform` — agent receives usage guidance on first turn |
226
- | Consolidation pipeline (crystals/auto + consolidate-pipeline) | Now called on `session.deleted` — mirrors Claude's `CONSOLIDATION_ENABLED=true` behavior |
227
- | Claude MEMORY.md bridge | OpenCode-specific; OpenCode uses its own AGENTS.md mechanism, not Claude's MEMORY.md |
228
-
229
- All other Claude Code hooks have direct or pipeline equivalents in this plugin. 12 of 12 Claude hook types covered.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/opencode/agentmemory-capture.ts DELETED
@@ -1,687 +0,0 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
2
-
3
- const API = process.env.AGENTMEMORY_URL || "http://localhost:3111";
4
- const FILE_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]);
5
- const FILE_KEYS = ["filePath", "file_path", "path", "file", "pattern"];
6
- const MAX_STASHED_FILES = 20;
7
-
8
- const DEBUG = process.env.OPENCODE_AGENTMEMORY_DEBUG === "1";
9
- const SECRET = process.env.AGENTMEMORY_SECRET || "";
10
-
11
- function authHeaders(): Record<string, string> {
12
- const headers: Record<string, string> = { "Content-Type": "application/json" };
13
- if (SECRET) headers["Authorization"] = `Bearer ${SECRET}`;
14
- return headers;
15
- }
16
-
17
- async function post(path: string, body: Record<string, unknown>, timeoutMs = 5000): Promise<void> {
18
- try {
19
- await fetch(`${API}/agentmemory${path}`, {
20
- method: "POST",
21
- headers: authHeaders(),
22
- body: JSON.stringify(body),
23
- signal: AbortSignal.timeout(timeoutMs),
24
- });
25
- } catch (e) {
26
- if (DEBUG) console.error(`[agentmemory] POST ${path} failed:`, (e as Error).message);
27
- }
28
- }
29
-
30
- async function postJson(path: string, body: Record<string, unknown>): Promise<unknown | null> {
31
- try {
32
- const res = await fetch(`${API}/agentmemory${path}`, {
33
- method: "POST",
34
- headers: authHeaders(),
35
- body: JSON.stringify(body),
36
- signal: AbortSignal.timeout(5000),
37
- });
38
- return res.ok ? await res.json() : null;
39
- } catch (e) {
40
- if (DEBUG) console.error(`[agentmemory] POST ${path} failed:`, (e as Error).message);
41
- return null;
42
- }
43
- }
44
-
45
- async function observe(
46
- sessionId: string,
47
- hookType: string,
48
- data: Record<string, unknown>,
49
- ): Promise<void> {
50
- await post("/observe", {
51
- hookType,
52
- sessionId,
53
- project: projectPath,
54
- cwd: projectPath,
55
- timestamp: new Date().toISOString(),
56
- data,
57
- });
58
- }
59
-
60
- let activeSessionId: string | null = null;
61
- let pendingConfig: Record<string, unknown> | null = null;
62
- let projectPath: string | null = null;
63
- const stashedFiles = new Map<string, Set<string>>();
64
- const seenSubtaskIds = new Map<string, Set<string>>();
65
- const seenToolCallIds = new Map<string, Set<string>>();
66
- const contextInjectedSessions = new Set<string>();
67
- // cache the context returned by POST /session/start so the chat
68
- // system-transform hook can inject it without a second /context fetch.
69
- // Auto-injection now happens at session.created (immediately) AND at
70
- // the first prompt_submit (fallback for older OpenCode builds that
71
- // don't implement experimental.chat.system.transform).
72
- const startContextCache = new Map<string, string>();
73
-
74
- function stashFor(sid: string): Set<string> {
75
- let s = stashedFiles.get(sid);
76
- if (!s) { s = new Set<string>(); stashedFiles.set(sid, s); }
77
- return s;
78
- }
79
-
80
- function subtaskSetFor(sid: string): Set<string> {
81
- let s = seenSubtaskIds.get(sid);
82
- if (!s) { s = new Set<string>(); seenSubtaskIds.set(sid, s); }
83
- return s;
84
- }
85
-
86
- function toolCallSetFor(sid: string): Set<string> {
87
- let s = seenToolCallIds.get(sid);
88
- if (!s) { s = new Set<string>(); seenToolCallIds.set(sid, s); }
89
- return s;
90
- }
91
-
92
- function pruneSessionMaps(sid: string): void {
93
- stashedFiles.delete(sid);
94
- seenSubtaskIds.delete(sid);
95
- seenToolCallIds.delete(sid);
96
- }
97
-
98
- function safeSlice(v: unknown, max: number): string {
99
- if (typeof v === "string") return v.slice(0, max);
100
- if (v == null) return "";
101
- try { return JSON.stringify(v).slice(0, max); } catch { return ""; }
102
- }
103
-
104
- const AGENTMEMORY_INSTRUCTIONS = `<agentmemory-instructions>
105
- You have access to agentmemory for persistent cross-session memory. Use these tools proactively.
106
-
107
- CORE TOOLS:
108
-
109
- memory_save — Save an insight, decision, or fact to long-term memory.
110
- Required: content (text), concepts (2-5 comma-separated keywords), type (pattern/preference/architecture/bug/workflow/fact)
111
- Optional: files (comma-separated paths)
112
- Use when: user says "remember this", after discovering a bug, after making an architectural decision, after learning a project convention.
113
-
114
- memory_recall — Search past observations by keywords.
115
- Use when: user says "recall", "what did we do", "do you remember", or needs context from past sessions.
116
-
117
- memory_smart_search — Hybrid semantic+keyword search with progressive disclosure.
118
- Use when: you need the most relevant past context, fuzzy/conceptual searches, or recall doesn't find what you need.
119
-
120
- memory_sessions — List recent sessions with status and observation counts.
121
- Use when: user asks about session/past history, "what did we work on".
122
-
123
- memory_file_history — Get past observations about specific files (across all sessions).
124
- Use when: you're about to edit a file and want to know its history, common pitfalls, or past edits.
125
-
126
- memory_lesson_save — Save a lesson learned (what worked, what to avoid).
127
- Use when: you discover a pattern that could help future sessions avoid mistakes.
128
-
129
- memory_lesson_recall — Search lessons by query. Returns lessons sorted by confidence.
130
- Use when: before making a decision, check if past lessons apply.
131
-
132
- memory_governance_delete ��� Delete specific memories. Requires explicit user confirmation.
133
- Use when: user says "forget this", "delete that memory".
134
-
135
- memory_patterns — Detect recurring patterns across sessions.
136
- Use when: you want to understand project-level trends over time.
137
-
138
- memory_consolidate — Run the 4-tier memory consolidation pipeline.
139
- Use when: you want to compress and organize accumulated session observations.
140
-
141
- 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.
142
- </agentmemory-instructions>`;
143
-
144
- function extractFilePaths(args: Record<string, unknown>): string[] {
145
- const files: string[] = [];
146
- for (const key of FILE_KEYS) {
147
- const val = args[key];
148
- if (typeof val === "string" && val.length > 0) {
149
- files.push(val);
150
- }
151
- }
152
- return files;
153
- }
154
-
155
- function extractErrorMessage(err: unknown): string {
156
- if (typeof err === "string") return err;
157
- if (err && typeof err === "object") {
158
- const e = err as Record<string, unknown>;
159
- if (typeof e.message === "string") return e.message;
160
- if (e.data && typeof e.data === "object") {
161
- const d = e.data as Record<string, unknown>;
162
- if (typeof d.message === "string") return d.message;
163
- }
164
- if (typeof e.name === "string") return e.name;
165
- try { return JSON.stringify(err); } catch { return ""; }
166
- }
167
- return String(err ?? "");
168
- }
169
-
170
- export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
171
- projectPath = ctx.worktree || ctx.project?.id || process.cwd();
172
-
173
- return {
174
- event: async ({ event }) => {
175
- const type = event.type;
176
- const props = (event as any).properties || {};
177
-
178
- // ── session.created ──
179
- if (type === "session.created") {
180
- const info = props.info as Record<string, unknown> | undefined;
181
- activeSessionId = (info?.id as string) || props.sessionID || null;
182
- if (!activeSessionId) return;
183
- stashedFiles.set(activeSessionId, new Set());
184
- seenSubtaskIds.delete(activeSessionId);
185
- seenToolCallIds.delete(activeSessionId);
186
- contextInjectedSessions.delete(activeSessionId);
187
- // Snapshot the session id locally — `activeSessionId` is mutable
188
- // and another `session.created` event during the await could
189
- // rebind it, causing context to be cached against the wrong key.
190
- const sessionId = activeSessionId;
191
- const startResult = await postJson("/session/start", {
192
- sessionId,
193
- title: info?.title ?? null,
194
- parentID: info?.parentID ?? null,
195
- version: info?.version ?? null,
196
- project: projectPath,
197
- cwd: projectPath,
198
- });
199
- // cache the context returned at session/start so the
200
- // chat.system.transform hook injects it without a second fetch.
201
- const startCtx = (startResult as any)?.context;
202
- if (typeof startCtx === "string" && startCtx.length > 0) {
203
- startContextCache.set(sessionId, startCtx);
204
- }
205
- if (pendingConfig) {
206
- await observe(sessionId, "config_loaded", pendingConfig);
207
- pendingConfig = null;
208
- }
209
- }
210
-
211
- // ── session.idle ── (summarize handled in session.status idle branch)
212
-
213
- // ── session.status ──
214
- if (type === "session.status") {
215
- const status = props.status as Record<string, unknown> | undefined;
216
- const sid = props.sessionID || activeSessionId;
217
- if (!sid || !status) return;
218
- if (status.type === "idle") {
219
- await post("/summarize", { sessionId: sid });
220
- }
221
- await observe(sid, "session_status", {
222
- status_type: status.type,
223
- attempt: status.attempt ?? null,
224
- message: safeSlice(status.message, 2000),
225
- });
226
- }
227
-
228
- // ── session.compacted ──
229
- if (type === "session.compacted") {
230
- const sid = props.sessionID || activeSessionId;
231
- if (sid) {
232
- await post("/summarize", { sessionId: sid });
233
- await observe(sid, "session_compacted", {});
234
- }
235
- }
236
-
237
- // ── session.updated ──
238
- if (type === "session.updated") {
239
- const info = props.info as Record<string, unknown> | undefined;
240
- const sid = (info?.id as string) || props.sessionID || activeSessionId;
241
- if (!sid) return;
242
- await observe(sid, "session_updated", {
243
- title: info?.title ?? null,
244
- parentID: info?.parentID ?? null,
245
- additions: (info?.summary as any)?.additions ?? null,
246
- deletions: (info?.summary as any)?.deletions ?? null,
247
- files: (info?.summary as any)?.files ?? null,
248
- });
249
- }
250
-
251
- // ── session.diff ──
252
- if (type === "session.diff") {
253
- const sid = props.sessionID || activeSessionId;
254
- if (!sid || !Array.isArray(props.diff)) return;
255
- const diffs = props.diff as Array<Record<string, unknown>>;
256
- await observe(sid, "session_diff", {
257
- files: diffs.map(d => d.file),
258
- additions: diffs.reduce((s, d) => s + ((d.additions as number) || 0), 0),
259
- deletions: diffs.reduce((s, d) => s + ((d.deletions as number) || 0), 0),
260
- diffs: diffs.slice(0, 50),
261
- });
262
- }
263
-
264
- // ── session.deleted ──
265
- if (type === "session.deleted") {
266
- const sid = props.info?.id || props.sessionID || activeSessionId;
267
- if (!sid) {
268
- if (DEBUG) console.error("[agentmemory] session.deleted with no session ID");
269
- return;
270
- }
271
- await post("/session/end", { sessionId: sid });
272
- post("/crystals/auto", { olderThanDays: 7 }, 30000);
273
- post("/consolidate-pipeline", { tier: "all", force: true }, 30000);
274
- if (sid === activeSessionId) activeSessionId = null;
275
- stashedFiles.delete(sid);
276
- startContextCache.delete(sid);
277
- seenSubtaskIds.delete(sid);
278
- seenToolCallIds.delete(sid);
279
- contextInjectedSessions.delete(sid);
280
- }
281
-
282
- // ── session.error ──
283
- if (type === "session.error") {
284
- const sid = props.sessionID || activeSessionId;
285
- if (sid) {
286
- await observe(sid, "post_tool_failure", {
287
- tool_name: "session.error",
288
- tool_input: "",
289
- tool_output: safeSlice(props.error, 8000),
290
- });
291
- }
292
- }
293
-
294
- // ── message.updated ──
295
- if (type === "message.updated") {
296
- const info = props.info as Record<string, unknown> | undefined;
297
- if (!info) return;
298
-
299
- if (info.role === "assistant") {
300
- const sid = props.sessionID || (info.sessionID as string) || activeSessionId;
301
- if (!sid) return;
302
- const tokens = info.tokens as Record<string, unknown> | undefined;
303
- const error = info.error ? extractErrorMessage(info.error) : null;
304
- await observe(sid, "assistant_message", {
305
- messageID: info.id,
306
- parentID: info.parentID,
307
- modelID: info.modelID,
308
- providerID: info.providerID,
309
- mode: info.mode,
310
- cost: info.cost ?? 0,
311
- tokens: {
312
- input: tokens?.input ?? 0,
313
- output: tokens?.output ?? 0,
314
- reasoning: tokens?.reasoning ?? 0,
315
- cache_read: (tokens?.cache as any)?.read ?? 0,
316
- cache_write: (tokens?.cache as any)?.write ?? 0,
317
- },
318
- finish: info.finish ?? null,
319
- error,
320
- duration_ms: (info.time && typeof (info.time as any).completed === "number")
321
- ? (info.time as any).completed - ((info.time as any).created || 0)
322
- : null,
323
- });
324
- }
325
- }
326
-
327
- // ── message.removed ──
328
- if (type === "message.removed") {
329
- const sid = props.sessionID || activeSessionId;
330
- if (sid) {
331
- await observe(sid, "message_removed", {
332
- messageID: props.messageID,
333
- });
334
- }
335
- }
336
-
337
- // ── message.part.updated ──
338
- if (type === "message.part.updated") {
339
- const part = props.part as Record<string, unknown> | undefined;
340
- if (!part) return;
341
- const sid = (part.sessionID as string) || props.sessionID || activeSessionId;
342
- if (!sid) return;
343
-
344
- if (part.type === "subtask") {
345
- const subtaskId = part.id as string;
346
- if (!subtaskId) return;
347
- const subtaskSet = subtaskSetFor(sid);
348
- if (subtaskSet.has(subtaskId)) return;
349
- subtaskSet.add(subtaskId);
350
- await observe(sid, "subagent_start", {
351
- subtask_id: part.id,
352
- agent: part.agent,
353
- prompt: safeSlice(part.prompt, 4000),
354
- description: safeSlice(part.description, 2000),
355
- });
356
- return;
357
- }
358
-
359
- if (part.type === "tool") {
360
- const state = part.state as Record<string, unknown> | undefined;
361
- if (!state) return;
362
- const callId = part.callID as string;
363
- if (!callId) return;
364
- const toolName = part.tool as string;
365
-
366
- if (state.status === "completed") {
367
- const callSet = toolCallSetFor(sid);
368
- if (callSet.has(callId)) return;
369
- callSet.add(callId);
370
- const st = state as Record<string, unknown>;
371
- const rawTime = (st.time as any) || {};
372
- const startTime = typeof rawTime.start === "number" ? rawTime.start : null;
373
- const endTime = typeof rawTime.end === "number" ? rawTime.end : null;
374
- await observe(sid, "post_tool_use", {
375
- tool_name: toolName,
376
- call_id: callId,
377
- tool_input: safeSlice(st.input, 4000),
378
- tool_output: safeSlice(st.output, 8000),
379
- title: st.title ?? null,
380
- metadata: st.metadata || {},
381
- duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null,
382
- attachments: Array.isArray(st.attachments)
383
- ? (st.attachments as Array<Record<string, unknown>>).map(a => a.filename || a.url)
384
- : [],
385
- });
386
- } else if (state.status === "error") {
387
- const callSet = toolCallSetFor(sid);
388
- if (callSet.has(callId)) return;
389
- callSet.add(callId);
390
- const st = state as Record<string, unknown>;
391
- const rawTime = (st.time as any) || {};
392
- const startTime = typeof rawTime.start === "number" ? rawTime.start : null;
393
- const endTime = typeof rawTime.end === "number" ? rawTime.end : null;
394
- await observe(sid, "post_tool_failure", {
395
- tool_name: toolName,
396
- call_id: callId,
397
- tool_input: safeSlice(st.input, 4000),
398
- tool_output: safeSlice(st.error, 8000),
399
- duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null,
400
- });
401
- }
402
- return;
403
- }
404
-
405
- if (part.type === "step-finish") {
406
- await observe(sid, "step_finish", {
407
- messageID: part.messageID,
408
- reason: part.reason ?? null,
409
- cost: (part as any).cost ?? 0,
410
- input_tokens: ((part as any).tokens?.input as number) ?? 0,
411
- output_tokens: ((part as any).tokens?.output as number) ?? 0,
412
- reasoning_tokens: ((part as any).tokens?.reasoning as number) ?? 0,
413
- });
414
- return;
415
- }
416
-
417
- if (part.type === "reasoning") {
418
- await observe(sid, "reasoning", {
419
- messageID: part.messageID,
420
- text: safeSlice((part as any).text, 4000),
421
- });
422
- return;
423
- }
424
-
425
- if (part.type === "file") {
426
- const filename = (part as any).filename || (part as any).url || null;
427
- if (filename) stashFor(sid).add(filename);
428
- return;
429
- }
430
-
431
- if (part.type === "patch") {
432
- await observe(sid, "patch_applied", {
433
- messageID: part.messageID,
434
- hash: (part as any).hash,
435
- files: (part as any).files || [],
436
- });
437
- return;
438
- }
439
-
440
- if (part.type === "compaction") {
441
- await observe(sid, "compaction_event", {
442
- messageID: part.messageID,
443
- auto: (part as any).auto ?? false,
444
- });
445
- return;
446
- }
447
-
448
- if (part.type === "agent") {
449
- await observe(sid, "agent_selected", {
450
- messageID: part.messageID,
451
- name: (part as any).name,
452
- });
453
- return;
454
- }
455
-
456
- if (part.type === "retry") {
457
- await observe(sid, "retry_attempt", {
458
- messageID: part.messageID,
459
- attempt: (part as any).attempt,
460
- error: safeSlice((part as any).error, 2000),
461
- });
462
- return;
463
- }
464
- }
465
-
466
- // ── file.edited ──
467
- if (type === "file.edited") {
468
- const sid = props.sessionID || activeSessionId;
469
- if (sid && typeof props.file === "string" && props.file.length > 0) {
470
- const stash = stashFor(sid);
471
- stash.add(props.file);
472
- if (stash.size > MAX_STASHED_FILES) {
473
- const keep = [...stash].slice(-MAX_STASHED_FILES);
474
- stash.clear();
475
- for (const f of keep) stash.add(f);
476
- }
477
- }
478
- }
479
-
480
- // ── permission.updated ──
481
- if (type === "permission.updated") {
482
- const sid = props.sessionID || activeSessionId;
483
- if (!sid) return;
484
- await observe(sid, "notification", {
485
- notification_type: "permission_prompt",
486
- permission: props.type || "unknown",
487
- pattern: Array.isArray(props.pattern)
488
- ? props.pattern.join(", ")
489
- : (props.pattern || ""),
490
- tool_call_id: props.callID || null,
491
- title: props.title || props.type || "",
492
- metadata: props.metadata || {},
493
- });
494
- }
495
-
496
- // ── permission.replied ──
497
- if (type === "permission.replied") {
498
- const sid = props.sessionID || activeSessionId;
499
- if (!sid) return;
500
- await observe(sid, "permission_replied", {
501
- permission_id: props.permissionID || props.requestID || "",
502
- response: props.response || props.reply || "",
503
- });
504
- }
505
-
506
- // ── todo.updated ──
507
- if (type === "todo.updated") {
508
- const sid = props.sessionID || activeSessionId;
509
- const todos = Array.isArray(props.todos) ? props.todos.slice(0, 100) : [];
510
- if (!sid || todos.length === 0) return;
511
- const completed = todos.filter((t: any) => t.status === "completed");
512
- const active = todos.filter((t: any) => t.status !== "completed");
513
- await observe(sid, "task_completed", {
514
- completed: completed.map((t: any) => ({ content: t.content, priority: t.priority })),
515
- in_progress: active.map((t: any) => ({ content: t.content, priority: t.priority })),
516
- total: todos.length,
517
- });
518
- }
519
-
520
- // ── command.executed ──
521
- if (type === "command.executed") {
522
- const sid = props.sessionID || activeSessionId;
523
- if (sid) {
524
- await observe(sid, "command_executed", {
525
- name: props.name,
526
- arguments: props.arguments || "",
527
- });
528
- }
529
- }
530
- },
531
-
532
- // ── chat.message ──
533
- "chat.message": async (input, output) => {
534
- const sid = input.sessionID || activeSessionId;
535
- if (!sid) return;
536
- const parts = output.parts || [];
537
- const files = parts
538
- .filter((p: any) => p.type === "file")
539
- .map((p: any) => p.filename || p.url)
540
- .filter(Boolean);
541
- for (const f of files) {
542
- const stash = stashFor(sid);
543
- stash.add(f);
544
- if (stash.size > MAX_STASHED_FILES) {
545
- const keep = [...stash].slice(-MAX_STASHED_FILES);
546
- stash.clear();
547
- for (const k of keep) stash.add(k);
548
- }
549
- }
550
-
551
- const textParts = parts.filter((p: any) => p.type === "text" && !p.synthetic && !p.ignored);
552
- const userText = textParts.map((p: any) => p.text || "").join("\n");
553
-
554
- await observe(sid, "prompt_submit", {
555
- agent: input.agent ?? null,
556
- model: input.model ?? null,
557
- variant: input.variant ?? null,
558
- prompt: userText.slice(0, 8000),
559
- files: files.slice(0, 20),
560
- parts_summary: parts.map((p: any) => p.type).filter(Boolean),
561
- });
562
- },
563
-
564
- // ── chat.params ──
565
- "chat.params": async (input, output) => {
566
- if (!input.model || !output) return;
567
- const sid = input.sessionID || activeSessionId;
568
- if (!sid) return;
569
- await observe(sid, "llm_params", {
570
- agent: input.agent,
571
- model: `${input.model.providerID}/${input.model.id}`,
572
- provider_url: input.model.api?.url ?? null,
573
- temperature: output.temperature,
574
- topP: output.topP,
575
- max_output_tokens: input.model.limit?.output ?? null,
576
- context_limit: input.model.limit?.context ?? null,
577
- cost_1k_input: input.model.cost?.input ?? 0,
578
- cost_1k_output: input.model.cost?.output ?? 0,
579
- });
580
- },
581
-
582
- // ── tool.execute.before ──
583
- "tool.execute.before": async (input, output) => {
584
- if (!FILE_TOOLS.has(input.tool)) return;
585
- const sid = input.sessionID || activeSessionId;
586
- if (!sid) return;
587
- const args = output.args as Record<string, unknown> | undefined;
588
- if (!args) return;
589
- const stash = stashFor(sid);
590
- for (const fp of extractFilePaths(args)) {
591
- stash.add(fp);
592
- }
593
- if (stash.size > MAX_STASHED_FILES) {
594
- const keep = [...stash].slice(-MAX_STASHED_FILES);
595
- stash.clear();
596
- for (const f of keep) stash.add(f);
597
- }
598
- },
599
-
600
- // ── experimental.chat.system.transform ──
601
- "experimental.chat.system.transform": async (input, output) => {
602
- const sid = input.sessionID || activeSessionId;
603
- if (!sid) return;
604
-
605
- if (!contextInjectedSessions.has(sid)) {
606
- if (!Array.isArray(output.system)) return;
607
- output.system.push(AGENTMEMORY_INSTRUCTIONS);
608
- // prefer the context already fetched at session.created;
609
- // fall back to a fresh /context call if the cache missed (e.g.
610
- // session resumed across plugin reloads).
611
- let ctx = startContextCache.get(sid);
612
- if (typeof ctx !== "string" || ctx.length === 0) {
613
- const result = await postJson("/context", {
614
- sessionId: sid,
615
- project: projectPath,
616
- });
617
- ctx = (result as any)?.context;
618
- } else {
619
- startContextCache.delete(sid);
620
- }
621
- if (typeof ctx === "string" && ctx.length > 0) {
622
- output.system.push(ctx);
623
- }
624
- contextInjectedSessions.add(sid);
625
- }
626
-
627
- const stash = stashFor(sid);
628
- if (stash.size === 0) return;
629
- const files = [...stash].slice(0, 10);
630
-
631
- const enrichResult = await postJson("/enrich", {
632
- sessionId: sid,
633
- files,
634
- toolName: "enrich_inject",
635
- });
636
-
637
- const enrichCtx = (enrichResult as any)?.context;
638
- if (typeof enrichCtx === "string" && enrichCtx.length > 0) {
639
- if (Array.isArray(output.system)) {
640
- output.system.push(enrichCtx);
641
- }
642
- for (const f of files) stash.delete(f);
643
- }
644
- },
645
-
646
- // ── experimental.session.compacting (WIP) ──
647
- "experimental.session.compacting": async (input, output) => {
648
- const sid = input.sessionID || activeSessionId;
649
- if (!sid) return;
650
-
651
- const result = await postJson("/context", {
652
- sessionId: sid,
653
- project: projectPath,
654
- });
655
- const ctx = (result as any)?.context;
656
- if (typeof ctx === "string" && ctx.length > 0) {
657
- if (Array.isArray(output.context)) {
658
- output.context.push(ctx);
659
- }
660
- }
661
- },
662
-
663
- // ── config ──
664
- config: async (input) => {
665
- const payload: Record<string, unknown> = {
666
- theme: input.theme ?? null,
667
- model: input.model ?? null,
668
- autoupdate: input.autoupdate ?? null,
669
- agents: typeof input.agent === "object" && input.agent !== null && !Array.isArray(input.agent)
670
- ? Object.keys(input.agent as Record<string, unknown>)
671
- : Array.isArray(input.agent) ? input.agent : [],
672
- mcp_servers: typeof input.mcp === "object" && input.mcp !== null && !Array.isArray(input.mcp)
673
- ? Object.keys(input.mcp as Record<string, unknown>)
674
- : Array.isArray(input.mcp) ? input.mcp : [],
675
- providers: typeof input.provider === "object" && input.provider !== null && !Array.isArray(input.provider)
676
- ? Object.keys(input.provider as Record<string, unknown>)
677
- : Array.isArray(input.provider) ? input.provider : [],
678
- permission: input.permission ?? null,
679
- };
680
- if (activeSessionId) {
681
- await observe(activeSessionId, "config_loaded", payload);
682
- } else {
683
- pendingConfig = payload;
684
- }
685
- },
686
- };
687
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/opencode/commands/recall.md DELETED
@@ -1,19 +0,0 @@
1
- Search past session observations and lessons for relevant context. Wrap the `memory_smart_search` and `memory_lesson_recall` MCP tools.
2
-
3
- ## Usage
4
-
5
- ```
6
- /recall [query]
7
- ```
8
-
9
- ## Instructions
10
-
11
- 1. Call `memory_smart_search` with the query and `limit: 10` (hybrid BM25 + vector + graph search).
12
- 2. Call `memory_lesson_recall` with the same query and `limit: 5` (lesson search).
13
- 3. Combine results and present to the user:
14
- - Group by session
15
- - Show type, title, and narrative for each observation
16
- - Highlight high-importance (>= 7) observations
17
- - Show lessons separately with confidence scores
18
- 4. If no results, suggest 2-3 alternative search terms.
19
- 5. **Never hallucinate results.** Only present what the MCP tools actually return.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
plugin/opencode/commands/remember.md DELETED
@@ -1,19 +0,0 @@
1
- Explicitly save an insight, decision, or learning to agentmemory for future sessions. Wraps the `memory_save` MCP tool.
2
-
3
- ## Usage
4
-
5
- ```
6
- /remember [what to remember]
7
- ```
8
-
9
- ## Instructions
10
-
11
- 1. Analyze what needs to be remembered — extract the core insight, decision, or fact.
12
- 2. Extract 2-5 searchable concepts (lowercased keyword phrases). Prefer specific terms ("jwt-refresh-rotation" over "auth").
13
- 3. Extract relevant file paths the memory references.
14
- 4. Call `memory_save` with:
15
- - `content` — full text to remember (preserve user's phrasing)
16
- - `concepts` — extracted concept list
17
- - `files` — extracted file list (empty array if none)
18
- - `type` — choose from: pattern, preference, architecture, bug, workflow, fact
19
- 5. Confirm the save and show the concepts tagged so the user knows retrieval terms.