hippocamp 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -22,12 +22,14 @@ This repository is a local-first MCP package for Git-backed agent memory. Keep c
22
22
 
23
23
  ## Repository Map
24
24
 
25
- - `scripts/hippocamp.cjs`: small CLI wrapper for `dream`, `mcp`, `install-codex`, `install-claude`, and `install-grok`.
25
+ - `scripts/hippocamp.cjs`: small CLI wrapper for `install` (every agent CLI found), `dream`, `mcp`, `install-codex`, `install-claude`, and `install-grok`.
26
26
  - `scripts/hippocamp-dream.cjs`: offline Dream CLI for scheduled compaction of curated project wake-up files.
27
27
  - `scripts/hippocamp-memory.cjs`: local filesystem-backed Hippocamp memory helpers for MCP use.
28
28
  - `scripts/hippocamp-mcp.cjs`: local stdio MCP server for Hippocamp memory tools.
29
29
  - `scripts/eval-lagoon.cjs`: read-only retrieval check against real Lagoon cases in `<Lagoon root>/evals/retrieval-cases.json`.
30
+ - `scripts/eval-qa.cjs`: LongMemEval-S answer-accuracy check (reader and grader models) for Hippocamp snippets against the labeled answer sessions.
30
31
  - `scripts/eval-retrieval.cjs`: retrieval-only LongMemEval-S check for `searchMemory` against a BM25 reference.
32
+ - `scripts/install-common.cjs`: shared installer helpers: the MCP launch command and Lagoon repo preparation.
31
33
  - `scripts/install-claude.cjs`: one-step installer for Claude Code MCP setup and user-level `CLAUDE.md` guidance.
32
34
  - `scripts/install-codex.cjs`: one-step installer for the Hippocamp skill and MCP server in Codex.
33
35
  - `scripts/install-grok.cjs`: one-step installer for the Hippocamp skill, Grok home rules, and MCP server in Grok Build.
@@ -74,10 +76,11 @@ When changing code, use the smallest verification that proves the change:
74
76
  - Run `npm run mcp:smoke` when changing memory reads, root resolution, or wake-up behavior.
75
77
  - Run `npm run dream -- --help` when changing Dream CLI arguments.
76
78
  - Run `npm run eval:retrieval -- --limit 100` when changing search ranking or snippets; report Recall@5 against the BM25 row and snippet evidence. Also run `npm run eval:lagoon` when a Lagoon is available.
79
+ - Run `npm run eval:qa -- --limit 3 --dry-run` when changing `scripts/eval-qa.cjs`; a full run calls paid models, so run it only when asked.
77
80
 
78
81
  ## Notes For Future Agents
79
82
 
80
83
  - The cloud/API version is intentionally deferred.
81
84
  - Do not reintroduce Next.js, GitHub API token auth, or GitHub App auth for the local MVP.
82
85
  - Keep Dream minimal: scheduled coherent compaction, current/open curated files plus bounded thread evidence, one PR per project.
83
- - The local install story should stay: install MCP, point at Lagoon, rely on normal Git auth.
86
+ - The local install story should stay: `npx hippocamp@latest install`, a local Lagoon repo created if missing, normal Git auth. Installers never create remotes or push.
package/README.md CHANGED
@@ -6,13 +6,77 @@
6
6
  <img src="./assets/brand/hippocamp-mascot.png" alt="Hippocamp mascot" width="220" />
7
7
  </p>
8
8
 
9
- Hippocamp is local Git-backed memory for AI coding agents.
9
+ Agent memory you can `git log`.
10
10
 
11
- Agents can edit large codebases, run tests, and push commits, but every new session still starts with amnesia. Project decisions, user preferences, open threads, and "why we did this" context get scattered across chat history, scratch notes, and PR comments. The result is repeated explanations, stale assumptions, and agents rediscovering the same facts instead of continuing the work.
11
+ Hippocamp gives Claude Code, Codex, and Grok Build one shared memory: plain Markdown in a private Git repo you own. Every session starts by waking up from it, and every decision an agent records is a commit you can read, diff, and revert.
12
12
 
13
- Hippocamp gives Codex, Claude Code, and other MCP clients a small shared memory surface they can wake up from, update, and sync. The memory is plain Markdown in a private Git repo you own.
13
+ Open source (MIT). No account, no API key, no cloud service: it runs on your machine and works the moment it is installed. Your memory never leaves your computer except to a Git remote you choose. No database, no vector store, nothing to pay for.
14
14
 
15
- No database. No hosted memory service. No vector store. No separate token broker.
15
+ ```bash
16
+ npx hippocamp@latest install
17
+ ```
18
+
19
+ This installs Hippocamp into every supported agent CLI it finds, creates `~/.lagoon` as a local Git repo if it does not exist, and tells you how to add a private remote. Restart your agent, and it calls `wake_up` at the start of each task.
20
+
21
+ ## Why Not Built-In Memory?
22
+
23
+ Claude Code and Codex now ship their own memory. Use Hippocamp when you want:
24
+
25
+ - **One memory for every agent.** Claude Code, Codex, and Grok read and write the same repo. Built-in memory stays inside one tool.
26
+ - **History you can audit.** Every memory change is a Git commit with an author, a diff, and a timestamp. Roll back a bad memory like bad code.
27
+ - **Files you own.** Markdown in your own private repo, readable without any UI, portable across machines through your normal Git credentials.
28
+ - **Nothing to sign up for.** Open source, local, free. No account, no API key, no vendor that can change terms or shut down.
29
+ - **Reviewed compaction.** Optional Dream mode summarizes memory offline and proposes the result as a pull request.
30
+
31
+ ## Results
32
+
33
+ Every number below comes from a script in this repo, so you can rerun it. We report where Hippocamp falls short too.
34
+
35
+ ### Finding the right memory
36
+
37
+ LongMemEval-S, all 470 answerable questions, top 5 results, no model involved (`npm run eval:retrieval`).
38
+
39
+ | Measure | Hippocamp | BM25 reference |
40
+ | --- | ---: | ---: |
41
+ | Right session in top 5 (Recall@5) | 90.5% | 91.4% |
42
+ | At least one right session in top 5 (Hit@5) | 96.0% | 96.8% |
43
+ | Returned text contains the answer turn | 87.9% | n/a |
44
+ | Text returned per query | 5.9k chars | 65.5k chars |
45
+ | Search latency, cold index | 42 ms | n/a |
46
+
47
+ BM25 returns whole sessions; Hippocamp returns the matching paragraphs of each result.
48
+
49
+ ### Answering with it
50
+
51
+ 100 LongMemEval-S questions, reader Claude Sonnet 5, grader gpt-4o with the official LongMemEval grading prompts (`npm run eval:qa`).
52
+
53
+ | What the reader gets | Text per question | Answer accuracy |
54
+ | --- | ---: | ---: |
55
+ | Hippocamp, top 5 | 6.7k chars | 71% |
56
+ | Hippocamp, top 10 | 12.9k chars | 77% |
57
+ | The correct sessions, given directly | 28.8k chars | 90% |
58
+
59
+ The same reader reaches 90% with the correct sessions, so the remaining gap is retrieval, not the reader.
60
+
61
+ ### Real coding questions
62
+
63
+ 53 questions about real work in a personal Lagoon across 13 projects (`npm run eval:lagoon`). This is our own test set, kept in the private Lagoon.
64
+
65
+ | Measure | Result |
66
+ | --- | ---: |
67
+ | Right event in top 5, 43 answerable questions | 42 / 43 |
68
+ | No results for 10 questions no memory is about | 10 / 10 |
69
+
70
+ ### Why less text matters
71
+
72
+ Memory results share the agent's context with code, diffs, and tool output, and agents search several times per task. About 6k characters per search is roughly 1.5k tokens; whole sessions would be about 16k. Fewer tokens make each turn cheaper and faster, and keep the relevant lines from being buried. Less text is only useful when the answer is still in it, which is why we report answer accuracy next to text size.
73
+
74
+ ### Limits
75
+
76
+ - Recall@5 is just under the BM25 reference, by less than 1 point, with 11 times less text.
77
+ - No embeddings or semantic search: a memory written in other words can be missed. On LongMemEval-S, 9 of 470 questions miss with no word in common with the answer.
78
+ - Answer accuracy is measured on 100 questions, about plus or minus 4 points.
79
+ - Self-reported scores from other memory systems use their own readers and graders, so they are not directly comparable with these.
16
80
 
17
81
  ## What It Stores
18
82
 
@@ -33,65 +97,28 @@ Git is a practical default for agent memory:
33
97
 
34
98
  ## Install
35
99
 
36
- Clone this repo, install dependencies, then install the MCP server for your agent:
37
-
38
100
  ```bash
39
- npm install
40
- npm run install:codex
101
+ npx hippocamp@latest install
41
102
  ```
42
103
 
43
- For Claude Code:
104
+ To install into one agent only:
44
105
 
45
106
  ```bash
46
- npm install
47
- npm run install:claude
107
+ npx hippocamp@latest install-claude
108
+ npx hippocamp@latest install-codex
109
+ npx hippocamp@latest install-grok
48
110
  ```
49
111
 
50
- For Grok Build:
112
+ Upgrade with the same command: `npx hippocamp@latest upgrade`. Installers default to `~/.lagoon` as the memory repo; pass `--global-root /absolute/path/to/lagoon` to use another clone.
51
113
 
52
- ```bash
53
- npm install
54
- npm run install:grok
55
- ```
56
-
57
- After publishing, the intended one-line install shape is:
58
-
59
- ```bash
60
- npx hippocamp install-codex
61
- ```
62
-
63
- Upgrade uses the same install path, so agents can refresh themselves without a separate state model:
64
-
65
- ```bash
66
- npx hippocamp@latest upgrade-codex
67
- npx hippocamp@latest upgrade-claude
68
- npx hippocamp@latest upgrade-grok
69
- ```
70
-
71
- From a source checkout:
72
-
73
- ```bash
74
- git pull --ff-only
75
- npm install
76
- npm run upgrade:codex
77
- npm run upgrade:claude
78
- npm run upgrade:grok
79
- ```
80
-
81
- Installers default to `~/.lagoon` as the memory repo. You can override it:
82
-
83
- ```bash
84
- npm run install:codex -- --global-root /absolute/path/to/lagoon
85
- npm run install:claude -- --global-root /absolute/path/to/lagoon
86
- npm run install:grok -- --global-root /absolute/path/to/lagoon
87
- ```
114
+ Installed through npx, the agent starts the server with `npx -y --prefer-offline hippocamp@<version> mcp`, so it keeps working if npm cleans its cache. From a source checkout, `npm install` then `npm run install:claude` (or `install:codex`, `install:grok`) registers the checkout directly.
88
115
 
89
116
  The Codex installer refreshes `~/.codex/AGENTS.md`. The Claude installer refreshes `~/.claude/CLAUDE.md`. The Grok installer refreshes `~/.grok/rules/hippocamp.md` and registers the MCP server with `HIPPOCAMP_AGENT=grok`.
90
117
  Managed instruction blocks tell the agent to call `wake_up` at the start of new top-level coding tasks before repo exploration or edits.
91
118
 
92
119
  ## Lagoon Repo
93
120
 
94
- Hippocamp expects a local Git repo for memory:
121
+ Hippocamp keeps memory in a local Git repo. The installer creates `~/.lagoon` if it is missing. To use memory you already have on another machine, clone it first:
95
122
 
96
123
  ```bash
97
124
  git clone git@github.com:YOUR_USER/lagoon.git ~/.lagoon
@@ -99,6 +126,12 @@ git clone git@github.com:YOUR_USER/lagoon.git ~/.lagoon
99
126
 
100
127
  The repo should usually be private. Pushing memory to a private remote keeps it available across machines and agent environments while still using normal Git access controls. Hippocamp does not need a GitHub token for local MCP mode; it uses your normal local Git credentials.
101
128
 
129
+ Memory works locally without a remote. To back it up privately and share it across machines, create a private remote:
130
+
131
+ ```bash
132
+ gh repo create lagoon --private --source ~/.lagoon --remote origin --push
133
+ ```
134
+
102
135
  If push auth is not configured yet, use your preferred GitHub setup. With GitHub CLI:
103
136
 
104
137
  ```bash
@@ -253,6 +286,7 @@ npm run mcp:smoke
253
286
  npm run dream
254
287
  npm run dream:railway
255
288
  npm run eval:lagoon
289
+ npm run eval:qa
256
290
  npm run eval:retrieval
257
291
  npm run install:codex
258
292
  npm run install:claude
@@ -266,6 +300,8 @@ npm run upgrade:grok
266
300
 
267
301
  `npm run eval:lagoon` runs the same kind of check, read-only, against your real Lagoon. It reads cases from `<Lagoon root>/evals/retrieval-cases.json`: a project, a query, and the event ids that answer it. An empty list means no memory should be returned. Keep the cases in Lagoon, not in this repo, because they quote private work.
268
302
 
303
+ `npm run eval:qa` measures answer accuracy on the same LongMemEval-S data with the official LongMemEval reader and grader prompts. It compares Hippocamp top 5 (`hippocamp`), top 10 (`hippocamp-k10`), and the labeled answer sessions (`oracle`). It calls an OpenAI-compatible endpoint from `MANIFEST_BASE_URL` and `MANIFEST_API_KEY`; set `QA_READER_MODEL` and `QA_GRADER_MODEL` to change models. Results are cached in `.context/qa-results.jsonl`, and `--dry-run` shows the jobs and token estimates without any API call.
304
+
269
305
  ## Releases
270
306
 
271
307
  Pull requests and pushes to `main` run syntax and MCP smoke checks in GitHub Actions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippocamp",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "private": false,
5
5
  "description": "Local Git-backed memory for AI agents.",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "dream": "node scripts/hippocamp-dream.cjs",
32
32
  "dream:railway": "bash assets/railway/run-dream.sh",
33
33
  "eval:lagoon": "node scripts/eval-lagoon.cjs",
34
+ "eval:qa": "node scripts/eval-qa.cjs",
34
35
  "eval:retrieval": "node scripts/eval-retrieval.cjs",
35
36
  "install:claude": "node scripts/install-claude.cjs",
36
37
  "install:codex": "node scripts/install-codex.cjs",
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env node
2
+
3
+ const crypto = require("node:crypto");
4
+ const fsSync = require("node:fs");
5
+ const fs = require("node:fs/promises");
6
+ const os = require("node:os");
7
+ const path = require("node:path");
8
+ const memory = require("./hippocamp-memory.cjs");
9
+
10
+ const DEFAULT_DATA = ".context/longmemeval/longmemeval_s_cleaned.json";
11
+ const DEFAULT_CACHE = ".context/qa-results.jsonl";
12
+ const DEFAULT_SYSTEMS = "hippocamp,oracle";
13
+ const SYSTEMS = ["hippocamp", "hippocamp-k10", "oracle"];
14
+ const DATA_URL =
15
+ "https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json";
16
+
17
+ // Prompts are verbatim from the official LongMemEval repo (xiaowu0162/LongMemEval):
18
+ // src/generation/run_generation.py for the reader, src/evaluation/evaluate_qa.py for the grader.
19
+ const READER_PROMPT =
20
+ "I will give you several history chats between you and a user. Please answer the question based on the relevant chat history. Answer the question step by step: first extract all the relevant information, and then reason over the information to get the answer.\n\n\nHistory Chats:\n\n{}\n\nCurrent Date: {}\nQuestion: {}\nAnswer (step by step):";
21
+
22
+ const BASE_CHECK =
23
+ "I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response is equivalent to the correct answer or contains all the intermediate steps to get the correct answer, you should also answer yes. If the response only contains a subset of the information required by the answer, answer no. ";
24
+ const GRADER_PROMPTS = {
25
+ "single-session-user": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
26
+ "single-session-assistant": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
27
+ "multi-session": `${BASE_CHECK}\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
28
+ "temporal-reasoning": `${BASE_CHECK}In addition, do not penalize off-by-one errors for the number of days. If the question asks for the number of days/weeks/months, etc., and the model makes off-by-one errors (e.g., predicting 19 days when the answer is 18), the model's response is still correct. \n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.`,
29
+ "knowledge-update":
30
+ "I will give you a question, a correct answer, and a response from a model. Please answer yes if the response contains the correct answer. Otherwise, answer no. If the response contains some previous information along with an updated answer, the response should be considered as correct as long as the updated answer is the required answer.\n\nQuestion: {}\n\nCorrect Answer: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.",
31
+ "single-session-preference":
32
+ "I will give you a question, a rubric for desired personalized response, and a response from a model. Please answer yes if the response satisfies the desired response. Otherwise, answer no. The model does not need to reflect all the points in the rubric. The response is correct as long as it recalls and utilizes the user's personal information correctly.\n\nQuestion: {}\n\nRubric: {}\n\nModel Response: {}\n\nIs the model response correct? Answer yes or no only.",
33
+ };
34
+
35
+ function printHelp() {
36
+ console.log(`Hippocamp QA eval
37
+
38
+ LongMemEval-S answer accuracy. A reader model answers each question from the
39
+ retrieved history, and a grader model checks the answer, both with the official
40
+ LongMemEval prompts. Calls go to an OpenAI-compatible chat completions endpoint.
41
+
42
+ Systems:
43
+ hippocamp searchMemory top 5 snippets
44
+ hippocamp-k10 searchMemory top 10 snippets
45
+ oracle the labeled answer sessions, in full
46
+
47
+ Usage:
48
+ npm run eval:qa -- [--data PATH] [--limit N] [--systems LIST] [--cache PATH] [--dry-run]
49
+
50
+ Options:
51
+ --data PATH LongMemEval-S JSON. Default: ${DEFAULT_DATA}
52
+ --limit N Evaluate N questions spread evenly across the set. Default: all answerable questions
53
+ --systems LIST Comma-separated systems. Default: ${DEFAULT_SYSTEMS}
54
+ --cache PATH JSONL results cache; reruns only pay for missing calls. Default: ${DEFAULT_CACHE}
55
+ --concurrency N Parallel reader and grader calls. Default: 6
56
+ --dry-run Print jobs and prompt token estimates without any API call
57
+ --help Show this help
58
+
59
+ Environment:
60
+ MANIFEST_BASE_URL, MANIFEST_API_KEY OpenAI-compatible endpoint and key
61
+ QA_READER_MODEL Default: anthropic/claude-sonnet-5
62
+ QA_GRADER_MODEL Default: openrouter/openai/gpt-4o
63
+
64
+ Download the data once:
65
+ mkdir -p .context/longmemeval
66
+ curl -L -o ${DEFAULT_DATA} \\
67
+ ${DATA_URL}
68
+ `);
69
+ }
70
+
71
+ function parsePositiveInteger(value, name) {
72
+ const number = Number(value);
73
+
74
+ if (!Number.isInteger(number) || number < 1) {
75
+ throw new Error(`${name} must be a positive integer.`);
76
+ }
77
+
78
+ return number;
79
+ }
80
+
81
+ function parseArgs(argv) {
82
+ const options = {
83
+ data: DEFAULT_DATA,
84
+ limit: null,
85
+ systems: DEFAULT_SYSTEMS.split(","),
86
+ cache: DEFAULT_CACHE,
87
+ concurrency: 6,
88
+ dryRun: false,
89
+ help: false,
90
+ };
91
+
92
+ for (let index = 0; index < argv.length; index += 1) {
93
+ const token = argv[index];
94
+
95
+ if (token === "--help" || token === "-h") {
96
+ options.help = true;
97
+ } else if (token === "--data") {
98
+ options.data = argv[++index];
99
+ } else if (token === "--limit") {
100
+ options.limit = parsePositiveInteger(argv[++index], "--limit");
101
+ } else if (token === "--systems") {
102
+ options.systems = String(argv[++index] || "").split(",").filter(Boolean);
103
+
104
+ for (const system of options.systems) {
105
+ if (!SYSTEMS.includes(system)) {
106
+ throw new Error(`Unknown system: ${system}. Use ${SYSTEMS.join(", ")}.`);
107
+ }
108
+ }
109
+ } else if (token === "--cache") {
110
+ options.cache = argv[++index];
111
+ } else if (token === "--concurrency") {
112
+ options.concurrency = parsePositiveInteger(argv[++index], "--concurrency");
113
+ } else if (token === "--dry-run") {
114
+ options.dryRun = true;
115
+ } else {
116
+ throw new Error(`Unknown argument: ${token}`);
117
+ }
118
+ }
119
+
120
+ return options;
121
+ }
122
+
123
+ function fill(template, ...values) {
124
+ return values.reduce((text, value) => text.replace("{}", () => String(value)), template);
125
+ }
126
+
127
+ function formatSession(sessionId, date, turns) {
128
+ const lines = [`# Session ${sessionId}`, "", `Date: ${date || "unknown"}`, ""];
129
+
130
+ for (const turn of turns || []) {
131
+ lines.push(`## ${turn.role === "assistant" ? "Assistant" : "User"}`, "", String(turn.content || "").trim(), "");
132
+ }
133
+
134
+ return `${lines.join("\n").trim()}\n`;
135
+ }
136
+
137
+ function historyString(chunks) {
138
+ return chunks
139
+ .sort((left, right) => left.date.localeCompare(right.date))
140
+ .map((chunk, index) => `\n### Session ${index + 1}:\nSession Date: ${chunk.date}\nSession Content:\n${chunk.content}\n`)
141
+ .join("");
142
+ }
143
+
144
+ async function hippocampChunks(item, projectRoot, maxResults) {
145
+ const pathToIndex = new Map();
146
+
147
+ for (let index = 0; index < item.haystack_sessions.length; index += 1) {
148
+ const relativePath = `sessions/${String(index).padStart(4, "0")}.md`;
149
+ const content = formatSession(item.haystack_session_ids[index], item.haystack_dates[index], item.haystack_sessions[index]);
150
+
151
+ await memory.writeMemoryFile({ scope: "project", path: relativePath, content, projectRoot, sync: false });
152
+ pathToIndex.set(relativePath, index);
153
+ }
154
+
155
+ const search = await memory.searchMemory({ query: item.question, scope: "project", projectRoot, maxResults });
156
+
157
+ return search.results.map((result) => ({
158
+ date: item.haystack_dates[pathToIndex.get(result.path)],
159
+ content: `\n${result.snippet}`,
160
+ }));
161
+ }
162
+
163
+ function oracleChunks(item) {
164
+ const answerIds = new Set(item.answer_session_ids.map(String));
165
+
166
+ return item.haystack_sessions
167
+ .map((turns, index) => ({ id: String(item.haystack_session_ids[index]), date: item.haystack_dates[index], turns }))
168
+ .filter((session) => answerIds.has(session.id))
169
+ .map((session) => ({
170
+ date: session.date,
171
+ content: `\n${JSON.stringify(session.turns.map(({ role, content }) => ({ role, content })))}`,
172
+ }));
173
+ }
174
+
175
+ // Models that rejected temperature (Claude 5 does); the official runs use 0 wherever it is accepted.
176
+ const NO_TEMPERATURE = new Set();
177
+
178
+ async function chat(endpoint, model, content, maxTokens) {
179
+ for (let attempt = 1; ; attempt += 1) {
180
+ try {
181
+ const body = { model, messages: [{ role: "user", content }], max_tokens: maxTokens };
182
+
183
+ if (!NO_TEMPERATURE.has(model)) {
184
+ body.temperature = 0;
185
+ }
186
+
187
+ const response = await fetch(endpoint, {
188
+ method: "POST",
189
+ headers: { Authorization: `Bearer ${process.env.MANIFEST_API_KEY}`, "Content-Type": "application/json" },
190
+ body: JSON.stringify(body),
191
+ });
192
+ const text = await response.text();
193
+
194
+ // Checks this request, not the set: parallel calls can all be refused before the first one marks the model.
195
+ if (response.status === 400 && /temperature/i.test(text) && "temperature" in body) {
196
+ NO_TEMPERATURE.add(model);
197
+ attempt -= 1;
198
+ continue;
199
+ }
200
+
201
+ if (!response.ok) {
202
+ throw Object.assign(new Error(`HTTP ${response.status}: ${text.slice(0, 300)}`), { status: response.status });
203
+ }
204
+
205
+ return JSON.parse(text).choices[0].message.content || "";
206
+ } catch (error) {
207
+ const retryable = !error.status || error.status === 429 || error.status >= 500;
208
+
209
+ if (!retryable || attempt >= 5) {
210
+ throw error;
211
+ }
212
+
213
+ await new Promise((resolve) => setTimeout(resolve, 2000 * attempt));
214
+ }
215
+ }
216
+ }
217
+
218
+ function percent(rows) {
219
+ return `${((rows.filter((row) => row.correct).length / Math.max(rows.length, 1)) * 100).toFixed(1)}%`;
220
+ }
221
+
222
+ async function run(options) {
223
+ if (!fsSync.existsSync(options.data)) {
224
+ throw new Error(`Missing ${options.data}. Run with --help for the download command.`);
225
+ }
226
+
227
+ const reader = process.env.QA_READER_MODEL || "anthropic/claude-sonnet-5";
228
+ const grader = process.env.QA_GRADER_MODEL || "openrouter/openai/gpt-4o";
229
+ const baseUrl = String(process.env.MANIFEST_BASE_URL || "").replace(/\/+$/, "");
230
+ const endpoint = `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/chat/completions`;
231
+ // The reader prompt's hash is in the key, so a change to the data or to retrieval never reuses a stale answer.
232
+ const promptHash = (prompt) => crypto.createHash("sha1").update(prompt).digest("hex").slice(0, 12);
233
+ const key = (system, id, hash) => `${system}|${reader}|${grader}|${id}|${hash}`;
234
+
235
+ const answerable = JSON.parse(await fs.readFile(options.data, "utf8")).filter(
236
+ (item) => !String(item.question_id).endsWith("_abs"),
237
+ );
238
+ const limit = Math.min(options.limit || answerable.length, answerable.length);
239
+ const items = Array.from({ length: limit }, (_, index) => answerable[Math.floor((index * answerable.length) / limit)]);
240
+ const done = new Map();
241
+
242
+ if (fsSync.existsSync(options.cache)) {
243
+ for (const line of (await fs.readFile(options.cache, "utf8")).split("\n").filter(Boolean)) {
244
+ const row = JSON.parse(line);
245
+ done.set(`${row.system}|${row.reader}|${row.grader}|${row.id}|${row.promptHash}`, row);
246
+ }
247
+ }
248
+
249
+ // Build every prompt first (retrieval is local and sequential), then call models concurrently.
250
+ const jobs = [];
251
+ const current = new Map();
252
+ const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-eval-qa-"));
253
+ const previousGlobalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
254
+
255
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(sandboxRoot, "lagoon");
256
+
257
+ try {
258
+ for (const [position, item] of items.entries()) {
259
+ const projectRoot = path.join(sandboxRoot, "questions", String(position));
260
+
261
+ for (const system of options.systems) {
262
+ const chunks =
263
+ system === "oracle" ? oracleChunks(item) : await hippocampChunks(item, projectRoot, system === "hippocamp-k10" ? 10 : 5);
264
+ const prompt = fill(READER_PROMPT, historyString(chunks), item.question_date, item.question);
265
+ const hash = promptHash(prompt);
266
+
267
+ current.set(`${system}|${item.question_id}`, key(system, item.question_id, hash));
268
+
269
+ if (!done.has(key(system, item.question_id, hash))) {
270
+ jobs.push({ system, item, prompt, hash });
271
+ }
272
+ }
273
+
274
+ await fs.rm(process.env.HIPPOCAMP_GLOBAL_ROOT, { recursive: true, force: true });
275
+ }
276
+ } finally {
277
+ if (previousGlobalRoot === undefined) {
278
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
279
+ } else {
280
+ process.env.HIPPOCAMP_GLOBAL_ROOT = previousGlobalRoot;
281
+ }
282
+
283
+ await fs.rm(sandboxRoot, { recursive: true, force: true });
284
+ }
285
+
286
+ console.error(`${items.length} questions, ${jobs.length} new jobs; reader ${reader}, grader ${grader}`);
287
+
288
+ for (const system of options.systems) {
289
+ const chars = jobs.filter((job) => job.system === system).reduce((sum, job) => sum + job.prompt.length, 0);
290
+ console.error(` ${system}: ${Math.round(chars / 4)} reader prompt tokens (estimate)`);
291
+ }
292
+
293
+ if (options.dryRun) {
294
+ return;
295
+ }
296
+
297
+ if (jobs.length && (!baseUrl || !process.env.MANIFEST_API_KEY)) {
298
+ throw new Error("Set MANIFEST_BASE_URL and MANIFEST_API_KEY.");
299
+ }
300
+
301
+ await fs.mkdir(path.dirname(path.resolve(options.cache)), { recursive: true });
302
+ let next = 0;
303
+ let finished = 0;
304
+ const worker = async () => {
305
+ while (next < jobs.length) {
306
+ const job = jobs[next++];
307
+ const response = await chat(endpoint, reader, job.prompt, 800);
308
+ const verdict = await chat(
309
+ endpoint,
310
+ grader,
311
+ fill(GRADER_PROMPTS[job.item.question_type], job.item.question, job.item.answer, response),
312
+ 10,
313
+ );
314
+ const row = {
315
+ system: job.system,
316
+ reader,
317
+ grader,
318
+ id: job.item.question_id,
319
+ type: job.item.question_type,
320
+ // Same check as the official evaluate_qa.py: 'yes' in the lowercased verdict.
321
+ correct: /yes/i.test(verdict),
322
+ promptHash: job.hash,
323
+ contextChars: job.prompt.length,
324
+ response,
325
+ };
326
+
327
+ await fs.appendFile(options.cache, `${JSON.stringify(row)}\n`);
328
+ done.set(key(row.system, row.id, row.promptHash), row);
329
+
330
+ if (++finished % 20 === 0) {
331
+ console.error(`progress ${finished}/${jobs.length}`);
332
+ }
333
+ }
334
+ };
335
+
336
+ await Promise.all(Array.from({ length: options.concurrency }, worker));
337
+
338
+ console.log(`LongMemEval-S QA, ${items.length} answerable questions, reader ${reader}, grader ${grader}\n`);
339
+
340
+ for (const system of options.systems) {
341
+ const rows = items.map((item) => done.get(current.get(`${system}|${item.question_id}`))).filter(Boolean);
342
+ const contextChars = Math.round(rows.reduce((sum, row) => sum + row.contextChars, 0) / Math.max(rows.length, 1));
343
+ console.log(`${system.padEnd(14)} QA ${percent(rows)} (${rows.length} graded, prompt ${contextChars} chars)`);
344
+ }
345
+
346
+ for (const system of options.systems) {
347
+ const rows = items.map((item) => done.get(current.get(`${system}|${item.question_id}`))).filter(Boolean);
348
+ console.log(`\n${system}`);
349
+
350
+ for (const type of [...new Set(rows.map((row) => row.type))].sort()) {
351
+ const byType = rows.filter((row) => row.type === type);
352
+ console.log(` ${type.padEnd(26)} ${percent(byType).padStart(6)} (${byType.length})`);
353
+ }
354
+ }
355
+ }
356
+
357
+ async function main() {
358
+ const options = parseArgs(process.argv.slice(2));
359
+
360
+ if (options.help) {
361
+ printHelp();
362
+ return;
363
+ }
364
+
365
+ await run(options);
366
+ }
367
+
368
+ main().catch((error) => {
369
+ console.error(error.message);
370
+ process.exit(1);
371
+ });
@@ -162,7 +162,7 @@ async function main() {
162
162
  "search_memory",
163
163
  {
164
164
  description:
165
- "Search task-specific memory. Event search uses cue indexes and returns only bounded coherent evidence; it does not scan unindexed event logs as a fallback. Event results carry their ISO timestamp as id; use it for when-questions.",
165
+ "Search task-specific memory. Returns no results when the question's distinctive words appear in no memory, unless the question names a date. Event search uses cue indexes and returns only bounded coherent evidence; it does not scan unindexed event logs as a fallback. Event results carry their ISO timestamp as id; use it for when-questions. A month name in the query favors events from that month. A YYYY-MM-DD date, today, yesterday, this/last week, or this/last month keeps only events from those dates (UTC); undated files still match. Default 5 results suits a single fact; ask for 10-20 when the question combines several past sessions (counts, totals, comparisons, timelines), and search again if results look incomplete.",
166
166
  inputSchema: {
167
167
  query: z.string(),
168
168
  scope: z.enum(["global", "project", "both"]).default("both"),