hippocamp 0.2.0 → 0.2.2

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.
@@ -23,3 +23,4 @@ jobs:
23
23
  - run: node --check scripts/*.cjs
24
24
  - run: npm run mcp:help
25
25
  - run: npm run mcp:smoke
26
+ - run: npm test
package/AGENTS.md CHANGED
@@ -26,6 +26,7 @@ This repository is a local-first MCP package for Git-backed agent memory. Keep c
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
+ - `scripts/eval-lagoon.cjs`: read-only retrieval check against real Lagoon cases in `<Lagoon root>/evals/retrieval-cases.json`.
29
30
  - `scripts/eval-retrieval.cjs`: retrieval-only LongMemEval-S check for `searchMemory` against a BM25 reference.
30
31
  - `scripts/install-claude.cjs`: one-step installer for Claude Code MCP setup and user-level `CLAUDE.md` guidance.
31
32
  - `scripts/install-codex.cjs`: one-step installer for the Hippocamp skill and MCP server in Codex.
@@ -72,7 +73,7 @@ When changing code, use the smallest verification that proves the change:
72
73
  - Run `npm run mcp:help` when changing MCP registration or commands.
73
74
  - Run `npm run mcp:smoke` when changing memory reads, root resolution, or wake-up behavior.
74
75
  - Run `npm run dream -- --help` when changing Dream CLI arguments.
75
- - Run `npm run eval:retrieval -- --limit 100` when changing search ranking or snippets; report Recall@5 against the BM25 row.
76
+ - 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.
76
77
 
77
78
  ## Notes For Future Agents
78
79
 
package/README.md CHANGED
@@ -252,6 +252,7 @@ npm run mcp:help
252
252
  npm run mcp:smoke
253
253
  npm run dream
254
254
  npm run dream:railway
255
+ npm run eval:lagoon
255
256
  npm run eval:retrieval
256
257
  npm run install:codex
257
258
  npm run install:claude
@@ -261,7 +262,9 @@ npm run upgrade:claude
261
262
  npm run upgrade:grok
262
263
  ```
263
264
 
264
- `npm run eval:retrieval` measures search on LongMemEval-S without any model: Recall@5, Hit@5, MRR, returned context size, and latency, next to an in-process BM25 reference. Run it with `--help` for the one-time data download.
265
+ `npm run eval:retrieval` measures search on LongMemEval-S without any model: Recall@5, Hit@5, MRR, snippet evidence (the returned text comes from an answer turn), returned context size, and latency, next to an in-process BM25 reference. Run it with `--help` for the one-time data download.
266
+
267
+ `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.
265
268
 
266
269
  ## Releases
267
270
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippocamp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "private": false,
5
5
  "description": "Local Git-backed memory for AI agents.",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "scripts": {
31
31
  "dream": "node scripts/hippocamp-dream.cjs",
32
32
  "dream:railway": "bash assets/railway/run-dream.sh",
33
+ "eval:lagoon": "node scripts/eval-lagoon.cjs",
33
34
  "eval:retrieval": "node scripts/eval-retrieval.cjs",
34
35
  "install:claude": "node scripts/install-claude.cjs",
35
36
  "install:codex": "node scripts/install-codex.cjs",
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fsSync = require("node:fs");
4
+ const fs = require("node:fs/promises");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const memory = require("./hippocamp-memory.cjs");
8
+
9
+ const MAX_TOP_K = 20;
10
+ const DEFAULT_CASES = "evals/retrieval-cases.json";
11
+
12
+ function printHelp() {
13
+ console.log(`Hippocamp Lagoon eval
14
+
15
+ Read-only retrieval check against your real Lagoon. No model or API key is used.
16
+ Each case names a project, a query, and the event ids that answer it.
17
+ An empty expect list means no memory should be returned.
18
+
19
+ Usage:
20
+ npm run eval:lagoon -- [--cases PATH] [--top-k K]
21
+
22
+ Options:
23
+ --cases PATH Cases JSON. Default: <Lagoon root>/${DEFAULT_CASES}
24
+ --top-k K Results retrieved per query, at most ${MAX_TOP_K}. Default: 5
25
+ --help Show this help
26
+
27
+ Cases file:
28
+ { "cases": [ { "project": "<slug>", "query": "...", "expect": ["<event id>"] } ] }
29
+ `);
30
+ }
31
+
32
+ function parseArgs(argv) {
33
+ const options = { cases: path.join(memory.getGlobalRoot(), DEFAULT_CASES), topK: 5, help: false };
34
+
35
+ for (let index = 0; index < argv.length; index += 1) {
36
+ const token = argv[index];
37
+
38
+ if (token === "--help" || token === "-h") {
39
+ options.help = true;
40
+ } else if (token === "--cases") {
41
+ options.cases = argv[++index];
42
+ } else if (token === "--top-k") {
43
+ const number = Number(argv[++index]);
44
+
45
+ if (!Number.isInteger(number) || number < 1 || number > MAX_TOP_K) {
46
+ throw new Error(`--top-k must be an integer from 1 to ${MAX_TOP_K}.`);
47
+ }
48
+
49
+ options.topK = number;
50
+ } else {
51
+ throw new Error(`Unknown argument: ${token}`);
52
+ }
53
+ }
54
+
55
+ return options;
56
+ }
57
+
58
+ async function run(options) {
59
+ if (!fsSync.existsSync(options.cases)) {
60
+ throw new Error(`Missing ${options.cases}. Run with --help for the file format.`);
61
+ }
62
+
63
+ const { cases } = JSON.parse(await fs.readFile(options.cases, "utf8"));
64
+ // searchMemory derives the project slug from the folder name, so a bare temp
65
+ // folder per slug reads that project's memory without touching any real repo.
66
+ const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-eval-lagoon-"));
67
+ const answerable = { count: 0, hit: 0, mrr: 0, chars: 0, topScores: [] };
68
+ const empty = { count: 0, correct: 0, topScores: [] };
69
+
70
+ try {
71
+ for (const item of cases) {
72
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(String(item.project))) {
73
+ throw new Error(`Invalid project slug: ${item.project}`);
74
+ }
75
+
76
+ const projectRoot = path.join(sandboxRoot, item.project);
77
+ await fs.mkdir(projectRoot, { recursive: true });
78
+
79
+ const search = await memory.searchMemory({
80
+ query: item.query,
81
+ scope: "project",
82
+ projectRoot,
83
+ maxResults: options.topK,
84
+ });
85
+ const topScore = search.results[0]?.score ?? 0;
86
+
87
+ if (!item.expect.length) {
88
+ empty.count += 1;
89
+ empty.correct += search.results.length ? 0 : 1;
90
+ empty.topScores.push(topScore);
91
+ console.log(`${search.results.length ? "MISS" : "ok "} empty top ${topScore} [${item.project}] ${item.query}`);
92
+ continue;
93
+ }
94
+
95
+ const rank = search.results.findIndex((result) => item.expect.includes(result.id));
96
+ answerable.count += 1;
97
+ answerable.hit += rank === -1 ? 0 : 1;
98
+ answerable.mrr += rank === -1 ? 0 : 1 / (rank + 1);
99
+ answerable.chars += search.results.reduce((sum, result) => sum + result.snippet.length, 0);
100
+ answerable.topScores.push(topScore);
101
+ console.log(
102
+ `${rank === -1 ? "MISS" : "ok "} rank ${rank === -1 ? "-" : rank + 1} top ${topScore} [${item.project}] ${item.query}`,
103
+ );
104
+ }
105
+ } finally {
106
+ await fs.rm(sandboxRoot, { recursive: true, force: true });
107
+ }
108
+
109
+ const percent = (part, whole) => `${((part / Math.max(whole, 1)) * 100).toFixed(1)}%`;
110
+ const range = (scores) => (scores.length ? `${Math.min(...scores)}-${Math.max(...scores)}` : "n/a");
111
+
112
+ console.log(`\nLagoon retrieval, K=${options.topK}, ${cases.length} cases`);
113
+ console.log(
114
+ `answerable (${answerable.count}) Hit@K ${percent(answerable.hit, answerable.count)} MRR ${(answerable.mrr / Math.max(answerable.count, 1)).toFixed(3)} context ${Math.round(answerable.chars / Math.max(answerable.count, 1))} chars top score ${range(answerable.topScores)}`,
115
+ );
116
+ console.log(
117
+ `no memory (${empty.count}) empty result ${percent(empty.correct, empty.count)} top score ${range(empty.topScores)}`,
118
+ );
119
+ }
120
+
121
+ async function main() {
122
+ const options = parseArgs(process.argv.slice(2));
123
+
124
+ if (options.help) {
125
+ printHelp();
126
+ return;
127
+ }
128
+
129
+ await run(options);
130
+ }
131
+
132
+ main().catch((error) => {
133
+ console.error(error.message);
134
+ process.exit(1);
135
+ });
@@ -132,6 +132,19 @@ function rankBm25(query, documents) {
132
132
  .map((item) => item.document);
133
133
  }
134
134
 
135
+ // Words too common to show that a question and its evidence share wording.
136
+ const STOPWORDS = new Set(
137
+ "about after also been before could does from have into just many much over some such than that their them then there these they this what when where which while with would your".split(" "),
138
+ );
139
+
140
+ function contentTokens(value) {
141
+ return new Set(tokenize(value).filter((token) => token.length > 3 && !STOPWORDS.has(token)));
142
+ }
143
+
144
+ function squashSpace(value) {
145
+ return String(value || "").replace(/\s+/g, " ").trim();
146
+ }
147
+
135
148
  function retrievalMetrics(retrievedIds, answerIds) {
136
149
  const expected = new Set(answerIds);
137
150
  const hits = retrievedIds.filter((id) => expected.has(id)).length;
@@ -145,10 +158,10 @@ function retrievalMetrics(retrievedIds, answerIds) {
145
158
  }
146
159
 
147
160
  function addMetrics(bucket, system, metrics) {
148
- const total = (bucket[system] ||= { recall: 0, hit: 0, mrr: 0, contextChars: 0, latencyMs: 0, count: 0 });
161
+ const total = (bucket[system] ||= { recall: 0, hit: 0, mrr: 0, evidence: 0, contextChars: 0, latencyMs: 0, count: 0 });
149
162
 
150
- for (const key of ["recall", "hit", "mrr", "contextChars", "latencyMs"]) {
151
- total[key] += metrics[key];
163
+ for (const key of ["recall", "hit", "mrr", "evidence", "contextChars", "latencyMs"]) {
164
+ total[key] += metrics[key] ?? Number.NaN;
152
165
  }
153
166
 
154
167
  total.count += 1;
@@ -162,6 +175,7 @@ function formatRow(label, total) {
162
175
  `Recall@K ${(average("recall") * 100).toFixed(1)}%`,
163
176
  `Hit@K ${(average("hit") * 100).toFixed(1)}%`,
164
177
  `MRR ${average("mrr").toFixed(3)}`,
178
+ Number.isNaN(total.evidence) ? "evidence n/a " : `evidence ${(average("evidence") * 100).toFixed(1)}%`,
165
179
  `context ${Math.round(average("contextChars"))} chars`,
166
180
  `latency ${average("latencyMs").toFixed(0)} ms`,
167
181
  ].join(" ");
@@ -181,6 +195,8 @@ async function run(options) {
181
195
  const previousGlobalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
182
196
  const overall = {};
183
197
  const byType = {};
198
+ const misses = [];
199
+ let withEvidenceTurns = 0;
184
200
 
185
201
  process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(sandboxRoot, "lagoon");
186
202
 
@@ -201,6 +217,12 @@ async function run(options) {
201
217
  }
202
218
 
203
219
  const answerIds = item.answer_session_ids.map(String);
220
+ const evidenceTurns = item.haystack_sessions
221
+ .filter((_, index) => answerIds.includes(String(item.haystack_session_ids[index])))
222
+ .flat()
223
+ .filter((turn) => turn.has_answer)
224
+ .map((turn) => squashSpace(turn.content));
225
+ withEvidenceTurns += evidenceTurns.length ? 1 : 0;
204
226
  let startedAt = performance.now();
205
227
  const search = await memory.searchMemory({
206
228
  query: item.question,
@@ -212,14 +234,31 @@ async function run(options) {
212
234
  ...retrievalMetrics(search.results.map((result) => pathToSessionId.get(result.path)), answerIds),
213
235
  latencyMs: performance.now() - startedAt,
214
236
  contextChars: search.results.reduce((sum, result) => sum + result.snippet.length, 0),
237
+ // The snippet itself comes from a turn marked has_answer, not only the right session.
238
+ evidence: search.results.some((result) =>
239
+ result.snippet
240
+ .split(/\n\s*\n/)
241
+ .map(squashSpace)
242
+ .some((part) => evidenceTurns.some((turn) => turn.includes(part) || part.includes(turn))),
243
+ )
244
+ ? 1
245
+ : 0,
215
246
  };
216
247
 
248
+ if (!hippocamp.hit && evidenceTurns.length) {
249
+ const shared = [...contentTokens(item.question)].filter((token) =>
250
+ evidenceTurns.some((turn) => contentTokens(turn).has(token)),
251
+ );
252
+ misses.push({ type: item.question_type, sharedWords: shared.length });
253
+ }
254
+
217
255
  startedAt = performance.now();
218
256
  const bm25Documents = rankBm25(item.question, documents).slice(0, options.topK);
219
257
  const bm25 = {
220
258
  ...retrievalMetrics(bm25Documents.map((document) => document.sessionId), answerIds),
221
259
  latencyMs: performance.now() - startedAt,
222
260
  contextChars: bm25Documents.reduce((sum, document) => sum + document.content.length, 0),
261
+ evidence: null,
223
262
  };
224
263
 
225
264
  for (const bucket of [overall, (byType[item.question_type] ||= {})]) {
@@ -250,6 +289,14 @@ async function run(options) {
250
289
  console.log(formatRow(system, total));
251
290
  }
252
291
 
292
+ const noSharedWords = misses.filter((miss) => miss.sharedWords === 0).length;
293
+ console.log(
294
+ `\nevidence: a hippocamp snippet is text from a has_answer turn (${withEvidenceTurns} questions have such turns).`,
295
+ );
296
+ console.log(
297
+ `wording: ${misses.length} hippocamp misses (Hit@K 0); ${noSharedWords} share no content word with the answer turns.`,
298
+ );
299
+
253
300
  for (const [type, systems] of Object.entries(byType)) {
254
301
  console.log(`\n${type} (${systems.bm25.count})`);
255
302
 
@@ -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.",
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.",
166
166
  inputSchema: {
167
167
  query: z.string(),
168
168
  scope: z.enum(["global", "project", "both"]).default("both"),
@@ -28,6 +28,8 @@ 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
+ const EVENT_LOCK_RETRY_MS = 10;
32
+ const EVENT_LOCK_STALE_MS = 10_000;
31
33
  const searchIndexCache = new Map();
32
34
  const PROCESS_SESSION_ID = `mcp-${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
33
35
 
@@ -924,8 +926,22 @@ function createSearchExcerpt(content, queryInfo) {
924
926
 
925
927
  index.addAll(blocks.map((text, position) => ({ position, text })));
926
928
 
927
- const [best] = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE });
928
- return best ? blocks[best.id] : null;
929
+ const picked = [];
930
+ let length = 0;
931
+
932
+ // Keep the best-matching paragraphs that fit the excerpt budget, in file order.
933
+ for (const hit of index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE })) {
934
+ const added = blocks[hit.id].length + (picked.length ? 2 : 0);
935
+
936
+ if (length + added > SEARCH_EXCERPT_MAX_CHARS) {
937
+ continue;
938
+ }
939
+
940
+ picked.push(hit.id);
941
+ length += added;
942
+ }
943
+
944
+ return picked.length ? picked.sort((left, right) => left - right).map((id) => blocks[id]).join("\n\n") : null;
929
945
  }
930
946
 
931
947
  async function readMemoryFile({ scope, path: relativePath, projectRoot }) {
@@ -976,6 +992,39 @@ async function writeMemoryFile({ scope, path: relativePath, content, projectRoot
976
992
  };
977
993
  }
978
994
 
995
+ // Serialize read-modify-write of one event file across processes. The lock lives
996
+ // in the temp directory so a crashed writer never leaves it in the Lagoon repo.
997
+ async function withEventFileLock(filePath, action) {
998
+ const lockName = crypto.createHash("sha1").update(filePath).digest("hex");
999
+ const lockPath = path.join(os.tmpdir(), `hippocamp-event-${lockName}.lock`);
1000
+
1001
+ for (;;) {
1002
+ try {
1003
+ await (await fs.open(lockPath, "wx")).close();
1004
+ break;
1005
+ } catch (error) {
1006
+ if (error.code !== "EEXIST") {
1007
+ throw error;
1008
+ }
1009
+
1010
+ const stats = await statIfExists(lockPath);
1011
+
1012
+ if (stats && Date.now() - stats.mtimeMs > EVENT_LOCK_STALE_MS) {
1013
+ await fs.rm(lockPath, { force: true });
1014
+ continue;
1015
+ }
1016
+
1017
+ await new Promise((resolve) => setTimeout(resolve, EVENT_LOCK_RETRY_MS));
1018
+ }
1019
+ }
1020
+
1021
+ try {
1022
+ return await action();
1023
+ } finally {
1024
+ await fs.rm(lockPath, { force: true });
1025
+ }
1026
+ }
1027
+
979
1028
  async function appendEvent({
980
1029
  scope = "project",
981
1030
  content,
@@ -998,18 +1047,30 @@ async function appendEvent({
998
1047
  : body.trim();
999
1048
  const attributionSection = formatAttributionSection(attribution);
1000
1049
  const eventBody = attributionSection ? `${attributionSection}\n\n${cuedBody}` : cuedBody;
1001
- const heading = title ? `## ${timestamp} — ${title.trim()}` : `## ${timestamp}`;
1002
- const eventBlock = `${heading}\n\n${eventBody}\n`;
1003
1050
  const relativePath = `events/${date}.md`;
1004
1051
  const target = resolveScopedPath(scope, relativePath, projectRoot);
1005
- const existing = await readFileIfExists(target.absolutePath);
1006
- const nextContent = existing?.trim()
1007
- ? `${existing.trimEnd()}\n\n${eventBlock.trimEnd()}\n`
1008
- : `# Events — ${date}\n\n${eventBlock.trimEnd()}\n`;
1009
1052
 
1010
1053
  await ensureParentDirectory(target.absolutePath);
1011
- await fs.writeFile(target.absolutePath, nextContent, "utf8");
1012
- const indexPath = await writeEventIndex(target.absolutePath, target.path);
1054
+
1055
+ const { eventBlock, indexPath } = await withEventFileLock(target.absolutePath, async () => {
1056
+ const existing = await readFileIfExists(target.absolutePath);
1057
+ const takenIds = new Set(parseEventBlocks(existing || "").map((event) => event.id));
1058
+
1059
+ // The timestamp is the event id, and search matches index entries to blocks by id.
1060
+ while (takenIds.has(timestamp) && !Number.isNaN(Date.parse(timestamp))) {
1061
+ timestamp = new Date(Date.parse(timestamp) + 1).toISOString();
1062
+ }
1063
+
1064
+ const heading = title ? `## ${timestamp} — ${title.trim()}` : `## ${timestamp}`;
1065
+ const block = `${heading}\n\n${eventBody}\n`;
1066
+ const nextContent = existing?.trim()
1067
+ ? `${existing.trimEnd()}\n\n${block.trimEnd()}\n`
1068
+ : `# Events — ${date}\n\n${block.trimEnd()}\n`;
1069
+
1070
+ await fs.writeFile(target.absolutePath, nextContent, "utf8");
1071
+
1072
+ return { eventBlock: block, indexPath: await writeEventIndex(target.absolutePath, target.path) };
1073
+ });
1013
1074
 
1014
1075
  const syncResult = sync
1015
1076
  ? await syncMemory({
@@ -116,7 +116,6 @@ function buildClaudeManagedBlock() {
116
116
  "- Do not duplicate commits, PRs, issues, reviews, or CI results in memory; store references plus the missing rationale, preference, assumption, or follow-up context.",
117
117
  "- If a project-memory write does not sync automatically, call `sync_memory` before finishing the task.",
118
118
  "- If the user asks to upgrade Hippocamp for Claude Code, run `npx hippocamp@latest upgrade-claude`; from a source checkout, run `git pull --ff-only`, `npm install`, then `npm run upgrade:claude`.",
119
- "- Before writing, reviewing, or refactoring code, read and follow `/Users/guillaumegay/.agents/skills/karpathy-guidelines/SKILL.md` unless the user explicitly asks to skip it.",
120
119
  CLAUDE_MANAGED_END,
121
120
  "",
122
121
  ].join("\n");
@@ -221,3 +221,128 @@ test("search returns whole sentences from an oversized matching paragraph", asyn
221
221
  assert.ok(search.results[0].snippet.length <= 1200);
222
222
  assert.match(search.results[0].snippet, /^The weekly sync|^The kestrel/);
223
223
  });
224
+
225
+ test("search returns every matching paragraph that fits the excerpt budget", async (t) => {
226
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
227
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-multi-paragraph-"));
228
+ const projectRoot = path.join(tempRoot, "multi-project");
229
+
230
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
231
+ await fs.mkdir(projectRoot, { recursive: true });
232
+ t.after(async () => {
233
+ if (originalRoot === undefined) {
234
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
235
+ } else {
236
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
237
+ }
238
+ await fs.rm(tempRoot, { recursive: true, force: true });
239
+ });
240
+
241
+ const filler = "The weekly sync covered routine updates.";
242
+ const paragraphs = [
243
+ "The kestrel move started on Monday.",
244
+ ...Array(30).fill(filler),
245
+ "The kestrel database move finished in eu-west-3 on Friday.",
246
+ ];
247
+ await memory.appendEvent({
248
+ content: paragraphs.join("\n\n"),
249
+ cues: ["infra"],
250
+ projectRoot,
251
+ scope: "project",
252
+ sync: false,
253
+ title: "Weekly sync",
254
+ });
255
+
256
+ const search = await memory.searchMemory({ projectRoot, query: "kestrel database move", scope: "project" });
257
+ const snippet = search.results[0].snippet;
258
+
259
+ // The last paragraph matches more query words, so only the file-order sort puts it second.
260
+ assert.ok(snippet.indexOf("started on Monday") < snippet.indexOf("finished in eu-west-3"));
261
+ assert.match(snippet, /The kestrel move started on Monday\./);
262
+ assert.match(snippet, /The kestrel database move finished in eu-west-3/);
263
+ assert.ok(snippet.length <= 1200);
264
+ });
265
+
266
+ test("events appended in the same millisecond keep distinct ids and bodies", async (t) => {
267
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
268
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-same-ms-"));
269
+ const projectRoot = path.join(tempRoot, "same-ms-project");
270
+
271
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
272
+ await fs.mkdir(projectRoot, { recursive: true });
273
+ t.after(async () => {
274
+ if (originalRoot === undefined) {
275
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
276
+ } else {
277
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
278
+ }
279
+ await fs.rm(tempRoot, { recursive: true, force: true });
280
+ });
281
+
282
+ const timestamp = "2026-10-01T09:00:00.000Z";
283
+ const first = await memory.appendEvent({
284
+ content: "The zanzibar migration needs a rollback plan.",
285
+ cues: ["deploy"],
286
+ projectRoot,
287
+ scope: "project",
288
+ sync: false,
289
+ timestamp,
290
+ title: "Deploy note",
291
+ });
292
+ const second = await memory.appendEvent({
293
+ content: "Invoices are generated monthly.",
294
+ cues: ["billing"],
295
+ projectRoot,
296
+ scope: "project",
297
+ sync: false,
298
+ timestamp,
299
+ title: "Billing note",
300
+ });
301
+
302
+ assert.equal(first.timestamp, timestamp);
303
+ assert.equal(second.timestamp, "2026-10-01T09:00:00.001Z");
304
+
305
+ const search = await memory.searchMemory({ projectRoot, query: "invoices monthly", scope: "project" });
306
+
307
+ assert.equal(search.results.length, 1);
308
+ assert.equal(search.results[0].heading, "Billing note");
309
+ assert.match(search.results[0].snippet, /Invoices are generated monthly\./);
310
+ });
311
+
312
+ test("parallel appends keep every event", async (t) => {
313
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
314
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-parallel-append-"));
315
+ const projectRoot = path.join(tempRoot, "parallel-project");
316
+
317
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
318
+ await fs.mkdir(projectRoot, { recursive: true });
319
+ t.after(async () => {
320
+ if (originalRoot === undefined) {
321
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
322
+ } else {
323
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
324
+ }
325
+ await fs.rm(tempRoot, { recursive: true, force: true });
326
+ });
327
+
328
+ const results = await Promise.all(
329
+ Array.from({ length: 20 }, (_, index) =>
330
+ memory.appendEvent({
331
+ content: `Parallel event ${index}.`,
332
+ cues: ["parallel"],
333
+ date: "2026-10-01",
334
+ projectRoot,
335
+ scope: "project",
336
+ sync: false,
337
+ timestamp: "2026-10-01T09:00:00.000Z",
338
+ title: `Event ${index}`,
339
+ }),
340
+ ),
341
+ );
342
+ const content = await fs.readFile(results[0].root + "/events/2026-10-01.md", "utf8");
343
+ const ids = [...content.matchAll(/^## (\S+)/gm)].map((match) => match[1]);
344
+
345
+ assert.equal(ids.length, 20);
346
+ assert.equal(new Set(ids).size, 20);
347
+ assert.equal(new Set(results.map((result) => result.timestamp)).size, 20);
348
+ });
@@ -0,0 +1,30 @@
1
+ const assert = require("node:assert/strict");
2
+ const { execFileSync } = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const test = require("node:test");
6
+
7
+ test("published files contain no local home directory paths", () => {
8
+ const root = path.join(__dirname, "..");
9
+ // npm is npm.cmd on Windows, which Node only runs through a shell.
10
+ const output = execFileSync("npm", ["pack", "--dry-run", "--json"], {
11
+ cwd: root,
12
+ encoding: "utf8",
13
+ shell: process.platform === "win32",
14
+ });
15
+ const [pack] = JSON.parse(output);
16
+ const textFiles = pack.files.map((file) => file.path).filter((file) => !file.endsWith(".png"));
17
+ const matches = [];
18
+
19
+ for (const file of textFiles) {
20
+ const content = fs.readFileSync(path.join(root, file), "utf8");
21
+
22
+ for (const [index, line] of content.split("\n").entries()) {
23
+ if (/\/Users\/[A-Za-z0-9._-]+|\/home\/[A-Za-z0-9._-]+/.test(line)) {
24
+ matches.push(`${file}:${index + 1}`);
25
+ }
26
+ }
27
+ }
28
+
29
+ assert.deepEqual(matches, []);
30
+ });