hippocamp 0.2.3 → 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.3",
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. 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.",
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"),
@@ -28,6 +28,11 @@ const SEARCH_FIELD_BOOST = { cues: 3, heading: 2 };
28
28
  const SEARCH_FUZZY_DISTANCE = 0.2;
29
29
  const SEARCH_INDEX_CACHE_LIMIT = 8;
30
30
  const SEARCH_EXCERPT_MAX_CHARS = 1200;
31
+ // Below this share of a question's rare-word weight, the best match is about something else.
32
+ const SEARCH_MIN_RARE_WORD_COVERAGE = 0.3;
33
+ const QUESTION_STOPWORDS = new Set(
34
+ "the and for with does did our are was were what which when where why how who can this that from have has had should would could there their them they you your use used about into after before over then than any all".split(" "),
35
+ );
31
36
  const EVENT_LOCK_RETRY_MS = 10;
32
37
  const EVENT_LOCK_STALE_MS = 10_000;
33
38
  const searchIndexCache = new Map();
@@ -434,6 +439,17 @@ async function getGitRepoRoot(startPath) {
434
439
  return result.stdout;
435
440
  }
436
441
 
442
+ // Resolve symlinks in a path whose last parts may not exist yet, so it compares with
443
+ // Git's top level, which Git always reports resolved (macOS /tmp is /private/tmp).
444
+ function realpathExisting(targetPath) {
445
+ try {
446
+ return fsSync.realpathSync(targetPath);
447
+ } catch {
448
+ const parent = path.dirname(targetPath);
449
+ return parent === targetPath ? targetPath : path.join(realpathExisting(parent), path.basename(targetPath));
450
+ }
451
+ }
452
+
437
453
  async function syncMemory({ scope, projectRoot, paths, message }) {
438
454
  const repoCandidate = getGlobalRepoRoot();
439
455
  const repoRoot = await getGitRepoRoot(repoCandidate);
@@ -450,7 +466,14 @@ async function syncMemory({ scope, projectRoot, paths, message }) {
450
466
 
451
467
  const repoRelativePaths = paths.map((item) => {
452
468
  const relativePath =
453
- path.relative(repoRoot, assertPathInScope(scopeRoot, item)).split(path.sep).join(path.posix.sep) || ".";
469
+ path
470
+ .relative(
471
+ realpathExisting(repoRoot),
472
+ // Recheck after resolving: a symlink inside the Lagoon must not reach another scope.
473
+ assertPathInScope(realpathExisting(scopeRoot), realpathExisting(assertPathInScope(scopeRoot, item))),
474
+ )
475
+ .split(path.sep)
476
+ .join(path.posix.sep) || ".";
454
477
 
455
478
  if (relativePath === ".") {
456
479
  throw new Error("path must point to a memory file or directory inside the Lagoon root.");
@@ -1293,9 +1316,32 @@ function rankSearchDocuments(queryInfo, { index, documents }) {
1293
1316
  .map((hit) => ({
1294
1317
  document: documents[hit.id],
1295
1318
  score: Math.round(hit.score * 100) / 100,
1319
+ queryTerms: hit.queryTerms,
1296
1320
  }));
1297
1321
  }
1298
1322
 
1323
+ // Share of the question's rare-word weight (IDF) that the best match contains. A question
1324
+ // whose rare words ("kubernetes", "namespace") appear in no memory gets a low share even when
1325
+ // common words ("backend", "deploy") match, so search can return nothing instead of noise.
1326
+ function rareWordCoverage(queryInfo, best, { index, documents }) {
1327
+ // Month names only rank (see eventDateText); they say when, not what.
1328
+ const words = [
1329
+ ...new Set(
1330
+ queryInfo.tokens.filter((token) => token.length > 2 && !QUESTION_STOPWORDS.has(token) && !MONTH_NAMES.includes(token)),
1331
+ ),
1332
+ ];
1333
+
1334
+ if (!words.length) {
1335
+ return 1;
1336
+ }
1337
+
1338
+ const weight = (word) => Math.log(1 + documents.length / (index.search(word, { fuzzy: 0 }).length + 0.5));
1339
+ const matched = new Set(best?.queryTerms || []);
1340
+ const total = words.reduce((sum, word) => sum + weight(word), 0);
1341
+
1342
+ return words.filter((word) => matched.has(word)).reduce((sum, word) => sum + weight(word), 0) / total;
1343
+ }
1344
+
1299
1345
  function toSearchResult(queryInfo, { document, score }) {
1300
1346
  const snippet = createSearchExcerpt(document.body, queryInfo, queryInfo.datePrefixes.length > 0);
1301
1347
 
@@ -1392,8 +1438,14 @@ async function searchMemory({ query, scope = "both", projectRoot, maxResults = 5
1392
1438
  const search = await loadSearchIndex(scopes, projectRoot);
1393
1439
  const results = [];
1394
1440
 
1395
- if (queryInfo.tokens.length && search.documents.length) {
1396
- for (const candidate of rankSearchDocuments(queryInfo, search)) {
1441
+ const candidates = queryInfo.tokens.length && search.documents.length ? rankSearchDocuments(queryInfo, search) : [];
1442
+ // Dated questions skip the check: their date words describe time, not the topic.
1443
+ const related =
1444
+ queryInfo.datePrefixes.length > 0 ||
1445
+ rareWordCoverage(queryInfo, candidates[0], search) >= SEARCH_MIN_RARE_WORD_COVERAGE;
1446
+
1447
+ if (related) {
1448
+ for (const candidate of candidates) {
1397
1449
  const result = toSearchResult(queryInfo, candidate);
1398
1450
 
1399
1451
  if (result) {
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { spawn } = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const { spawn, spawnSync } = require("node:child_process");
4
5
  const path = require("node:path");
5
6
 
7
+ // One installer per agent CLI; `install` runs the ones whose CLI is on PATH.
8
+ const AGENTS = [
9
+ ["claude", "install-claude.cjs"],
10
+ ["codex", "install-codex.cjs"],
11
+ ["grok", "install-grok.cjs"],
12
+ ];
13
+
6
14
  const COMMANDS = {
7
15
  dream: "hippocamp-dream.cjs",
8
16
  mcp: "hippocamp-mcp.cjs",
@@ -18,6 +26,8 @@ function printHelp() {
18
26
  console.log(`Hippocamp
19
27
 
20
28
  Usage:
29
+ hippocamp install
30
+ hippocamp upgrade
21
31
  hippocamp dream
22
32
  hippocamp mcp
23
33
  hippocamp install-codex
@@ -28,6 +38,8 @@ Usage:
28
38
  hippocamp upgrade-grok
29
39
 
30
40
  Commands:
41
+ install Install into every agent found: Claude Code, Codex, Grok Build
42
+ upgrade Same as install; run with npx hippocamp@latest
31
43
  dream Run offline Dream memory compaction
32
44
  mcp Run the local MCP server
33
45
  install-codex Install the Hippocamp skill and MCP server into Codex
@@ -39,6 +51,35 @@ Commands:
39
51
  `);
40
52
  }
41
53
 
54
+ // Platform-neutral PATH lookup, so `install` also finds agents on Windows.
55
+ function onPath(cli) {
56
+ const extensions = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD").split(";") : [""];
57
+
58
+ return String(process.env.PATH || "")
59
+ .split(path.delimiter)
60
+ .some((dir) => extensions.some((extension) => fs.existsSync(path.join(dir, cli + extension.toLowerCase())) || fs.existsSync(path.join(dir, cli + extension))));
61
+ }
62
+
63
+ function installAll(args) {
64
+ const found = AGENTS.filter(([cli]) => onPath(cli));
65
+
66
+ if (!found.length) {
67
+ console.error("No supported agent CLI found on PATH: claude, codex, or grok. Install one, then rerun.");
68
+ process.exit(1);
69
+ }
70
+
71
+ // Install every agent found, even when one fails, then report the failures.
72
+ const failed = found.filter(([cli, script]) => {
73
+ console.error(`Installing Hippocamp into ${cli}...`);
74
+ return spawnSync(process.execPath, [path.join(__dirname, script), ...args], { stdio: "inherit" }).status !== 0;
75
+ });
76
+
77
+ if (failed.length) {
78
+ console.error(`Install failed for: ${failed.map(([cli]) => cli).join(", ")}`);
79
+ process.exit(1);
80
+ }
81
+ }
82
+
42
83
  function main() {
43
84
  const [command, ...args] = process.argv.slice(2);
44
85
 
@@ -47,6 +88,11 @@ function main() {
47
88
  return;
48
89
  }
49
90
 
91
+ if (command === "install" || command === "upgrade") {
92
+ installAll(args);
93
+ return;
94
+ }
95
+
50
96
  const script = COMMANDS[command];
51
97
 
52
98
  if (!script) {
@@ -5,6 +5,7 @@ const os = require("node:os");
5
5
  const path = require("node:path");
6
6
  const { execFile } = require("node:child_process");
7
7
  const { promisify } = require("node:util");
8
+ const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
 
@@ -204,7 +205,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
204
205
  }
205
206
  }
206
207
 
207
- const scriptPath = path.join(repoRoot, "scripts", "hippocamp-mcp.cjs");
208
+ const command = mcpServerCommand(repoRoot);
208
209
  const added = await runClaude([
209
210
  "mcp",
210
211
  "add",
@@ -218,8 +219,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
218
219
  "--env",
219
220
  "HIPPOCAMP_AGENT=claude",
220
221
  "--",
221
- process.execPath,
222
- scriptPath,
222
+ ...command,
223
223
  ]);
224
224
 
225
225
  if (!added.ok) {
@@ -228,8 +228,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
228
228
 
229
229
  return {
230
230
  name: serverName,
231
- command: process.execPath,
232
- scriptPath,
231
+ command: command.join(" "),
233
232
  globalRoot,
234
233
  summary: added.stdout || added.stderr,
235
234
  };
@@ -249,6 +248,7 @@ async function main() {
249
248
  repoRoot,
250
249
  });
251
250
 
251
+ const lagoon = await prepareLagoon(options.globalRoot);
252
252
  const server = await ensureServerInstalled({
253
253
  repoRoot,
254
254
  globalRoot: options.globalRoot,
@@ -263,12 +263,14 @@ async function main() {
263
263
  claudeMdPath,
264
264
  claudeSkillPath,
265
265
  server,
266
+ lagoon,
266
267
  note: "Restart Claude Code to load the installed MCP server, Hippocamp skill, and refreshed CLAUDE.md instructions.",
267
268
  },
268
269
  null,
269
270
  2,
270
271
  ),
271
272
  );
273
+ printHints(lagoon);
272
274
  }
273
275
 
274
276
  main().catch((error) => {
@@ -5,6 +5,7 @@ const os = require("node:os");
5
5
  const path = require("node:path");
6
6
  const { execFile } = require("node:child_process");
7
7
  const { promisify } = require("node:util");
8
+ const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
  const CODEX_MANAGED_START = "<!-- hippocamp:managed:start -->";
@@ -174,7 +175,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
174
175
  }
175
176
  }
176
177
 
177
- const scriptPath = path.join(repoRoot, "scripts", "hippocamp-mcp.cjs");
178
+ const command = mcpServerCommand(repoRoot);
178
179
  const added = await runCodex([
179
180
  "mcp",
180
181
  "add",
@@ -184,8 +185,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
184
185
  "--env",
185
186
  "HIPPOCAMP_AGENT=codex",
186
187
  "--",
187
- process.execPath,
188
- scriptPath,
188
+ ...command,
189
189
  ]);
190
190
 
191
191
  if (!added.ok) {
@@ -194,8 +194,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
194
194
 
195
195
  return {
196
196
  name: serverName,
197
- command: process.execPath,
198
- scriptPath,
197
+ command: command.join(" "),
199
198
  globalRoot,
200
199
  summary: added.stdout || added.stderr,
201
200
  };
@@ -215,6 +214,7 @@ async function main() {
215
214
  codexHome: options.codexHome,
216
215
  });
217
216
 
217
+ const lagoon = await prepareLagoon(options.globalRoot);
218
218
  const server = await ensureServerInstalled({
219
219
  repoRoot,
220
220
  globalRoot: options.globalRoot,
@@ -229,12 +229,14 @@ async function main() {
229
229
  skillPath,
230
230
  codexInstructionsPath,
231
231
  server,
232
+ lagoon,
232
233
  note: "Restart Codex to load the installed skill, MCP server, and refreshed AGENTS.md instructions.",
233
234
  },
234
235
  null,
235
236
  2,
236
237
  ),
237
238
  );
239
+ printHints(lagoon);
238
240
  }
239
241
 
240
242
  main().catch((error) => {
@@ -0,0 +1,91 @@
1
+ const fsSync = require("node:fs");
2
+ const fs = require("node:fs/promises");
3
+ const path = require("node:path");
4
+ const { execFile } = require("node:child_process");
5
+ const { promisify } = require("node:util");
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ // How an agent starts the MCP server. Run through npx, the package lives in npm's cache,
10
+ // which npm may clean; a pinned npx command downloads it again instead of breaking.
11
+ function mcpServerCommand(repoRoot) {
12
+ if (!repoRoot.split(path.sep).includes("_npx")) {
13
+ return [process.execPath, path.join(repoRoot, "scripts", "hippocamp-mcp.cjs")];
14
+ }
15
+
16
+ const { version } = require(path.join(repoRoot, "package.json"));
17
+ const npx = path.join(path.dirname(process.execPath), process.platform === "win32" ? "npx.cmd" : "npx");
18
+
19
+ return [fsSync.existsSync(npx) ? npx : path.basename(npx), "-y", "--prefer-offline", `hippocamp@${version}`, "mcp"];
20
+ }
21
+
22
+ async function git(args, cwd) {
23
+ try {
24
+ const { stdout } = await execFileAsync("git", args, {
25
+ cwd,
26
+ timeout: 20_000,
27
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
28
+ });
29
+ return { ok: true, stdout: stdout.trim() };
30
+ } catch (error) {
31
+ return { ok: false, stdout: String(error.stdout || "").trim() };
32
+ }
33
+ }
34
+
35
+ // Make sure the Lagoon memory repo exists, and say what is still missing for sync.
36
+ // It never creates a remote or pushes: those reach outside this machine.
37
+ async function prepareLagoon(globalRoot) {
38
+ const hints = [];
39
+ let created = false;
40
+ const existed = fsSync.existsSync(globalRoot);
41
+ const empty = !existed || (await fs.readdir(globalRoot)).length === 0;
42
+
43
+ await fs.mkdir(globalRoot, { recursive: true });
44
+
45
+ // The Lagoon must be its own repo: a folder inside another repo would commit memory there.
46
+ const topLevel = await git(["rev-parse", "--show-toplevel"], globalRoot);
47
+ const ownRepo = topLevel.ok && fsSync.realpathSync(topLevel.stdout) === fsSync.realpathSync(globalRoot);
48
+
49
+ if (!ownRepo) {
50
+ if (!empty) {
51
+ hints.push(`${globalRoot} is not its own Git repo. Point --global-root at your Lagoon clone, or run: git -C "${globalRoot}" init`);
52
+ return { root: globalRoot, created: false, remote: null, push: "not_git", hints };
53
+ }
54
+
55
+ if (!(await git(["init", "-b", "main"], globalRoot)).ok) {
56
+ throw new Error(`git init failed in ${globalRoot}. Check that Git is installed and the folder is writable.`);
57
+ }
58
+
59
+ await git(["commit", "--allow-empty", "-m", "hippocamp: create lagoon"], globalRoot);
60
+ created = true;
61
+ }
62
+
63
+ const identity = await Promise.all(["user.name", "user.email"].map((key) => git(["config", key], globalRoot)));
64
+
65
+ if (identity.some((value) => !value.stdout)) {
66
+ hints.push('Set a Git identity so memory can be committed: git config --global user.name "Your Name" && git config --global user.email you@example.com');
67
+ }
68
+
69
+ const remote = await git(["remote", "get-url", "origin"], globalRoot);
70
+ let push = "no_remote";
71
+
72
+ if (!remote.ok) {
73
+ hints.push(`Memory is local only. For a private backup and other machines: gh repo create lagoon --private --source "${globalRoot}" --remote origin --push`);
74
+ } else if ((await git(["rev-parse", "--verify", "HEAD"], globalRoot)).ok) {
75
+ push = (await git(["push", "--dry-run"], globalRoot)).ok ? "ok" : "failed";
76
+
77
+ if (push === "failed") {
78
+ hints.push(`Git push from ${globalRoot} failed. Set up auth, then check with git push --dry-run: gh auth login && gh auth setup-git`);
79
+ }
80
+ }
81
+
82
+ return { root: globalRoot, created, remote: remote.ok ? remote.stdout : null, push, hints };
83
+ }
84
+
85
+ function printHints(lagoon) {
86
+ for (const hint of lagoon.hints) {
87
+ console.error(`Next: ${hint}`);
88
+ }
89
+ }
90
+
91
+ module.exports = { mcpServerCommand, prepareLagoon, printHints };
@@ -5,6 +5,7 @@ const os = require("node:os");
5
5
  const path = require("node:path");
6
6
  const { execFile } = require("node:child_process");
7
7
  const { promisify } = require("node:util");
8
+ const { mcpServerCommand, prepareLagoon, printHints } = require("./install-common.cjs");
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
  const GROK_MANAGED_START = "<!-- hippocamp:managed:start -->";
@@ -190,7 +191,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
190
191
  }
191
192
  }
192
193
 
193
- const scriptPath = path.join(repoRoot, "scripts", "hippocamp-mcp.cjs");
194
+ const command = mcpServerCommand(repoRoot);
194
195
  const added = await runGrok([
195
196
  "mcp",
196
197
  "add",
@@ -200,8 +201,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
200
201
  "-e",
201
202
  "HIPPOCAMP_AGENT=grok",
202
203
  "--",
203
- process.execPath,
204
- scriptPath,
204
+ ...command,
205
205
  ]);
206
206
 
207
207
  if (!added.ok) {
@@ -210,8 +210,7 @@ async function ensureServerInstalled({ repoRoot, globalRoot, serverName }) {
210
210
 
211
211
  return {
212
212
  name: serverName,
213
- command: process.execPath,
214
- scriptPath,
213
+ command: command.join(" "),
215
214
  globalRoot,
216
215
  agent: "grok",
217
216
  summary: added.stdout || added.stderr,
@@ -232,6 +231,7 @@ async function main() {
232
231
  grokHome: options.grokHome,
233
232
  });
234
233
 
234
+ const lagoon = await prepareLagoon(options.globalRoot);
235
235
  const server = await ensureServerInstalled({
236
236
  repoRoot,
237
237
  globalRoot: options.globalRoot,
@@ -246,12 +246,14 @@ async function main() {
246
246
  skillPath,
247
247
  grokInstructionsPath,
248
248
  server,
249
+ lagoon,
249
250
  note: "Restart Grok Build (or start a new session) to load the installed skill, MCP server, and home rules.",
250
251
  },
251
252
  null,
252
253
  2,
253
254
  ),
254
255
  );
256
+ printHints(lagoon);
255
257
  }
256
258
 
257
259
  main().catch((error) => {
@@ -25,6 +25,7 @@ Upgrade uses the same path: run `npm run upgrade:codex`, `npm run upgrade:claude
25
25
  1. At the start of every top-level coding task, call `wake_up` before inspecting files, planning, or editing.
26
26
  2. Read the wake-up output before searching.
27
27
  3. Use `search_memory` as the normal path for task-specific recall after wake-up.
28
+ Keep the default 5 results for a single fact. Pass `maxResults` 10-20 when the question combines several past sessions (counts, totals, comparisons, timelines), and search again if the results look incomplete.
28
29
  4. If search returns no result, continue without memory. Do not read whole event logs as a fallback.
29
30
  5. During work, use `append_event` only for meaningful milestones.
30
31
  6. When appending events, pass a `title` and 1-8 concise `cues` so fuzzy recall can find the event later. Never write under `events/` with `write_memory_file`.
@@ -0,0 +1,112 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { execFileSync } = require("node:child_process");
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 { mcpServerCommand, prepareLagoon } = require("../scripts/install-common.cjs");
9
+
10
+ // CI runners have no Git identity; commits in these tests need one.
11
+ const gitEnv = {
12
+ GIT_AUTHOR_NAME: "Test",
13
+ GIT_AUTHOR_EMAIL: "test@example.com",
14
+ GIT_COMMITTER_NAME: "Test",
15
+ GIT_COMMITTER_EMAIL: "test@example.com",
16
+ };
17
+
18
+ // Scoped to each test that needs a Git identity, then restored.
19
+ function useGitIdentity(t) {
20
+ const saved = Object.fromEntries(Object.keys(gitEnv).map((key) => [key, process.env[key]]));
21
+ Object.assign(process.env, gitEnv);
22
+ t.after(() => {
23
+ for (const [key, value] of Object.entries(saved)) {
24
+ if (value === undefined) {
25
+ delete process.env[key];
26
+ } else {
27
+ process.env[key] = value;
28
+ }
29
+ }
30
+ });
31
+ }
32
+
33
+ async function tempDir(t, name) {
34
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), `hippocamp-${name}-`));
35
+ t.after(() => fs.rm(root, { recursive: true, force: true, maxRetries: 5 }));
36
+ return root;
37
+ }
38
+
39
+ test("mcpServerCommand pins npx installs and keeps source checkouts direct", async (t) => {
40
+ const root = await tempDir(t, "npx");
41
+ const npxRoot = path.join(root, "_npx", "abc", "node_modules", "hippocamp");
42
+ await fs.mkdir(npxRoot, { recursive: true });
43
+ await fs.writeFile(path.join(npxRoot, "package.json"), '{ "version": "9.9.9" }');
44
+
45
+ assert.deepEqual(mcpServerCommand(npxRoot).slice(1), ["-y", "--prefer-offline", "hippocamp@9.9.9", "mcp"]);
46
+ const source = path.join(root, "src", "hippocamp");
47
+ assert.deepEqual(mcpServerCommand(source), [process.execPath, path.join(source, "scripts", "hippocamp-mcp.cjs")]);
48
+ });
49
+
50
+ test("prepareLagoon creates a missing Lagoon repo and says it is local only", async (t) => {
51
+ useGitIdentity(t);
52
+ const lagoon = path.join(await tempDir(t, "lagoon"), "lagoon");
53
+ const result = await prepareLagoon(lagoon);
54
+
55
+ assert.equal(result.created, true);
56
+ assert.equal(result.push, "no_remote");
57
+ assert.match(result.hints.join("\n"), /gh repo create lagoon --private/);
58
+ assert.equal(execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: lagoon }).toString().trim(), "true");
59
+ });
60
+
61
+ test("prepareLagoon leaves a non-empty folder that is not a repo alone", async (t) => {
62
+ const folder = await tempDir(t, "notes");
63
+ await fs.writeFile(path.join(folder, "notes.md"), "mine\n");
64
+ const result = await prepareLagoon(folder);
65
+
66
+ assert.equal(result.push, "not_git");
67
+ await assert.rejects(fs.stat(path.join(folder, ".git")));
68
+ });
69
+
70
+ test("prepareLagoon checks push to an existing remote", async (t) => {
71
+ useGitIdentity(t);
72
+ const root = await tempDir(t, "remote");
73
+ const remote = path.join(root, "remote.git");
74
+ const lagoon = path.join(root, "lagoon");
75
+ execFileSync("git", ["init", "--bare", "-b", "main", remote]);
76
+ execFileSync("git", ["init", "-b", "main", lagoon]);
77
+ execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: lagoon });
78
+ execFileSync("git", ["remote", "add", "origin", remote], { cwd: lagoon });
79
+ execFileSync("git", ["push", "-u", "origin", "main"], { cwd: lagoon, stdio: "ignore" });
80
+
81
+ const result = await prepareLagoon(lagoon);
82
+
83
+ assert.equal(result.created, false);
84
+ assert.equal(result.remote, remote);
85
+ assert.equal(result.push, "ok");
86
+ });
87
+
88
+ test("prepareLagoon refuses a folder inside another repo", async (t) => {
89
+ useGitIdentity(t);
90
+ const parent = await tempDir(t, "parent");
91
+ execFileSync("git", ["init", "-b", "main", parent]);
92
+ const nested = path.join(parent, "notes");
93
+ await fs.mkdir(nested);
94
+ await fs.writeFile(path.join(nested, "a.md"), "x\n");
95
+
96
+ const result = await prepareLagoon(nested);
97
+
98
+ assert.equal(result.push, "not_git");
99
+ await assert.rejects(fs.stat(path.join(nested, ".git")));
100
+ });
101
+
102
+ test("prepareLagoon gives an empty folder inside another repo its own repo", async (t) => {
103
+ useGitIdentity(t);
104
+ const parent = await tempDir(t, "parent-empty");
105
+ execFileSync("git", ["init", "-b", "main", parent]);
106
+ const nested = path.join(parent, "lagoon");
107
+
108
+ const result = await prepareLagoon(nested);
109
+
110
+ assert.equal(result.created, true);
111
+ assert.equal(fsSync.realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: nested }).toString().trim()), fsSync.realpathSync(nested));
112
+ });
@@ -463,3 +463,38 @@ test("search returns a snippet for a long event matched only by its date", async
463
463
  assert.match(search.results[0].snippet, /^The weekly sync covered routine updates\./);
464
464
  assert.ok(search.results[0].snippet.length <= 1200);
465
465
  });
466
+
467
+ test("search returns nothing when no memory is about the question", async (t) => {
468
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
469
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-unrelated-"));
470
+ const projectRoot = path.join(tempRoot, "unrelated-project");
471
+
472
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
473
+ await fs.mkdir(projectRoot, { recursive: true });
474
+ t.after(async () => {
475
+ if (originalRoot === undefined) {
476
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
477
+ } else {
478
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
479
+ }
480
+ await fs.rm(tempRoot, { recursive: true, force: true });
481
+ });
482
+
483
+ for (const [title, content] of [
484
+ ["Backend deploy", "The backend deploys to Cloud Run after the build passes."],
485
+ ["Deploy rollback", "A failed backend deploy rolls back to the previous revision."],
486
+ ["Billing note", "Invoices are generated monthly."],
487
+ ]) {
488
+ await memory.appendEvent({ content, cues: ["ops"], projectRoot, scope: "project", sync: false, title });
489
+ }
490
+
491
+ const unrelated = await memory.searchMemory({ projectRoot, query: "which kubernetes namespace does the backend deploy to", scope: "project" });
492
+ const related = await memory.searchMemory({ projectRoot, query: "where does the backend deploy", scope: "project" });
493
+
494
+ const monthOnly = await memory.searchMemory({ projectRoot, query: "deploy in august", scope: "project" });
495
+
496
+ assert.deepEqual(unrelated.results, []);
497
+ assert.equal(related.results[0].heading, "Backend deploy");
498
+ // No event is from August; the month name must not count as a missing topic word.
499
+ assert.ok(monthOnly.results.length > 0);
500
+ });
@@ -0,0 +1,67 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+ const { execFileSync } = require("node:child_process");
4
+ const fs = require("node:fs/promises");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const memory = require("../scripts/hippocamp-memory.cjs");
8
+
9
+ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
10
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
11
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-symlink-"));
12
+ const realLagoon = path.join(tempRoot, "real-lagoon");
13
+ const linkedLagoon = path.join(tempRoot, "linked-lagoon");
14
+ const projectRoot = path.join(tempRoot, "linked-project");
15
+ const git = (args) => execFileSync("git", args, { cwd: realLagoon }).toString().trim();
16
+
17
+ // CI runners have no Git identity; set one for this test only.
18
+ const identity = { GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com" };
19
+ const saved = Object.fromEntries(Object.keys(identity).map((key) => [key, process.env[key]]));
20
+ Object.assign(process.env, identity);
21
+ t.after(() => {
22
+ for (const [key, value] of Object.entries(saved)) {
23
+ if (value === undefined) {
24
+ delete process.env[key];
25
+ } else {
26
+ process.env[key] = value;
27
+ }
28
+ }
29
+ });
30
+
31
+ await fs.mkdir(realLagoon);
32
+ await fs.mkdir(projectRoot);
33
+ await fs.symlink(realLagoon, linkedLagoon);
34
+ git(["init", "-q", "-b", "main"]);
35
+ git(["commit", "-q", "--allow-empty", "-m", "init"]);
36
+ process.env.HIPPOCAMP_GLOBAL_ROOT = linkedLagoon;
37
+ t.after(async () => {
38
+ if (originalRoot === undefined) {
39
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
40
+ } else {
41
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
42
+ }
43
+ await fs.rm(tempRoot, { recursive: true, force: true, maxRetries: 5 });
44
+ });
45
+
46
+ const result = await memory.appendEvent({
47
+ content: "Use advisory locks.",
48
+ cues: ["locks"],
49
+ projectRoot,
50
+ scope: "project",
51
+ title: "Queue decision",
52
+ });
53
+
54
+ assert.equal(result.sync.committed, true);
55
+ assert.match(git(["log", "--format=%s", "-1"]), /append project event/);
56
+
57
+ // A symlink inside the Lagoon pointing at another project must not be synced as this one.
58
+ const otherProject = path.join(tempRoot, "other-project");
59
+ await fs.mkdir(otherProject);
60
+ await fs.mkdir(path.join(realLagoon, "projects", "other-project"), { recursive: true });
61
+ await fs.symlink(path.join(realLagoon, "projects", "linked-project", "events"), path.join(realLagoon, "projects", "other-project", "events"));
62
+
63
+ await assert.rejects(
64
+ memory.appendEvent({ content: "Cross write.", cues: ["x"], projectRoot: otherProject, scope: "project", title: "Escape" }),
65
+ /escapes the memory root/,
66
+ );
67
+ });