hippocamp 0.2.6 → 0.2.8

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.
@@ -0,0 +1,190 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs/promises");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const test = require("node:test");
6
+ const dream = require("../scripts/hippocamp-dream.cjs");
7
+ const memory = require("../scripts/hippocamp-memory.cjs");
8
+
9
+ async function fixture(t) {
10
+ const previousRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
11
+ const previousFetch = global.fetch;
12
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-dream-test-"));
13
+ process.env.HIPPOCAMP_GLOBAL_ROOT = root;
14
+ t.after(async () => {
15
+ if (previousRoot === undefined) delete process.env.HIPPOCAMP_GLOBAL_ROOT;
16
+ else process.env.HIPPOCAMP_GLOBAL_ROOT = previousRoot;
17
+ global.fetch = previousFetch;
18
+ await fs.rm(root, { recursive: true, force: true });
19
+ });
20
+ const slug = "dream-fixture";
21
+ const projectRoot = `/tmp/${slug}`;
22
+ const project = path.join(root, "projects", slug);
23
+ await fs.mkdir(project, { recursive: true });
24
+ await fs.writeFile(path.join(project, "current_state.md"), "# Current State\n\n- Keep production reads fail-closed.\n");
25
+ await fs.writeFile(path.join(project, "open_threads.md"), "# Open Threads\n\n- Merge PR #2928, then verify deployment.\n- Investigate the unresolved native Google blocker.\n");
26
+ const options = { thresholdChars: 20000, targetChars: 3000, write: true, apiKey: "test", baseUrl: "https://model.test", model: "test" };
27
+ async function event(title, content, offset = -60000) {
28
+ const timestamp = new Date(Date.now() + offset).toISOString();
29
+ await memory.appendEvent({ projectRoot, scope: "project", sync: false, title, content, keywords: ["dream-test"], timestamp, date: timestamp.slice(0, 10) });
30
+ return timestamp;
31
+ }
32
+ return { root, slug, projectRoot, project, options, event };
33
+ }
34
+
35
+ function respond(output) {
36
+ return { ok: true, status: 200, text: async () => JSON.stringify({ output_text: JSON.stringify(output) }) };
37
+ }
38
+
39
+ test("small projects receive new evidence and may grow; a successful batch is not reviewed twice", async (t) => {
40
+ const f = await fixture(t);
41
+ const id = await f.event("PR #2928 merged and a reusable lesson", "PR #2928 merged. Deployment has not been verified.\n\nDecision: project identity follows the canonical repository, because worktree names are temporary.");
42
+ const eventPath = path.join(f.project, "events", `${id.slice(0, 10)}.md`);
43
+ const originalEvents = await fs.readFile(eventPath, "utf8");
44
+ let calls = 0;
45
+ global.fetch = async (_url, request) => {
46
+ calls++;
47
+ const input = JSON.parse(request.body).input;
48
+ assert.match(input, /Recent Event Evidence/);
49
+ assert.ok(input.includes(id));
50
+ assert.match(input, /PR #2928 merged\. Deployment has not been verified/);
51
+ assert.match(input, /canonical repository/);
52
+ assert.match(input, /unresolved native Google blocker/);
53
+ assert.match(input, /A merged PR closes the merge action, not unverified deployment/);
54
+ return respond({
55
+ current_state_md: `# Current State\n\n- Keep production reads fail-closed.\n- Project identity follows the canonical repository because worktree names are temporary. Source: events/${id.slice(0, 10)}.md, ${id}.`,
56
+ open_threads_md: "# Open Threads\n\n- Verify PR #2928 deployment.\n- Investigate the unresolved native Google blocker.",
57
+ });
58
+ };
59
+ const scan = await dream.dreamProject(f.slug, { ...f.options, write: false });
60
+ assert.equal(scan.skipped, "dry_run");
61
+ assert.equal(calls, 0);
62
+ const result = await dream.dreamProject(f.slug, f.options);
63
+ assert.ok(result.afterBytes > result.beforeBytes);
64
+ assert.ok(result.afterBytes <= f.options.targetChars * 1.02);
65
+ assert.equal(result.recentEvidence.items.length, 1);
66
+ assert.equal(dream.toPublicReport(result).context.recentEvents, 1);
67
+ assert.ok((await fs.readFile(path.join(f.project, "current_state.md"), "utf8")).includes(`hippocamp-dream-through: ${id}`));
68
+ assert.equal(await fs.readFile(eventPath, "utf8"), originalEvents);
69
+ assert.equal((await dream.dreamProject(f.slug, f.options)).skipped, "below_threshold");
70
+ assert.equal(calls, 1);
71
+ });
72
+
73
+ test("bootstrap ignores old, future, and unindexed events; a recorded cursor survives downtime", async (t) => {
74
+ const f = await fixture(t);
75
+ const old = await f.event("Old event", "An older durable constraint.", -10 * 86400000);
76
+ await f.event("Future event", "Not yet evidence.", 86400000);
77
+ const today = new Date().toISOString().slice(0, 10);
78
+ await fs.writeFile(path.join(f.project, "events", `${today}.md`), `## ${new Date().toISOString()} — Unindexed\n\nDo not ingest.\n`);
79
+ assert.equal((await dream.dreamProject(f.slug, { ...f.options, write: false })).skipped, "below_threshold");
80
+ const marker = new Date(Date.now() - 11 * 86400000).toISOString();
81
+ await fs.appendFile(path.join(f.project, "current_state.md"), `\n<!-- hippocamp-dream-through: ${marker} -->\n`);
82
+ const resumed = await dream.dreamProject(f.slug, { ...f.options, write: false });
83
+ assert.equal(resumed.skipped, "dry_run");
84
+ assert.deepEqual(resumed.recentEvidence.items.map((e) => e.id), [old]);
85
+ });
86
+
87
+ test("Dream preserves existing curated-file permissions when replacing the snapshot", async (t) => {
88
+ const f = await fixture(t);
89
+ await f.event("Durable correction", "Keep production reads fail-closed.");
90
+ const currentPath = path.join(f.project, "current_state.md");
91
+ const threadsPath = path.join(f.project, "open_threads.md");
92
+ await fs.chmod(currentPath, 0o600);
93
+ await fs.chmod(threadsPath, 0o640);
94
+ global.fetch = async () => respond({ current_state_md: "# Current State\n\n- Keep production reads fail-closed.", open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
95
+ await dream.dreamProject(f.slug, f.options);
96
+ assert.equal((await fs.stat(currentPath)).mode & 0o777, 0o600);
97
+ assert.equal((await fs.stat(threadsPath)).mode & 0o777, 0o640);
98
+ });
99
+
100
+ test("bounded batches defer remaining events without advancing past unseen evidence", async (t) => {
101
+ const f = await fixture(t);
102
+ const ids = [];
103
+ for (let i = 0; i < 5; i++) ids.push(await f.event(`Lesson ${i}`, `Constraint ${i}: ${"Keep the documented boundary. ".repeat(18)}`, -60000 + i * 1000));
104
+ global.fetch = async () => respond({ current_state_md: "# Current State\n\n- Keep the documented boundary.", open_threads_md: "# Open Threads\n\n- Investigate the unresolved native Google blocker." });
105
+ const first = await dream.dreamProject(f.slug, f.options);
106
+ assert.ok(first.recentEvidence.items.length > 0 && first.recentEvidence.items.length < ids.length);
107
+ assert.equal(first.recentEvidence.reviewedThrough, first.recentEvidence.items.at(-1).id);
108
+ const second = await dream.dreamProject(f.slug, { ...f.options, write: false });
109
+ assert.equal(second.recentEvidence.items[0].id, ids[first.recentEvidence.items.length]);
110
+ const report = dream.toPublicReport(first);
111
+ assert.ok(report.context.evidenceChars <= report.context.evidenceBudgetChars);
112
+ });
113
+
114
+ test("an oversized model result fails without changing files or the event cursor", async (t) => {
115
+ const f = await fixture(t);
116
+ await f.event("Durable correction", "Keep production reads fail-closed.");
117
+ const paths = ["current_state.md", "open_threads.md"].map((name) => path.join(f.project, name));
118
+ const before = await Promise.all(paths.map((name) => fs.readFile(name, "utf8")));
119
+ global.fetch = async () => respond({ current_state_md: `# Current State\n\n- ${"Too large. ".repeat(400)}`, open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
120
+ await assert.rejects(dream.dreamProject(f.slug, f.options), /did not reduce the snapshot/);
121
+ assert.deepEqual(await Promise.all(paths.map((name) => fs.readFile(name, "utf8"))), before);
122
+ });
123
+
124
+ test("an oversized first excerpt fails before a model call without skipping evidence", async (t) => {
125
+ const f = await fixture(t);
126
+ await f.event("Durable correction", "Keep production reads fail-closed.");
127
+ const paths = ["current_state.md", "open_threads.md"].map((name) => path.join(f.project, name));
128
+ const before = await Promise.all(paths.map((name) => fs.readFile(name, "utf8")));
129
+ global.fetch = async () => assert.fail("Model must not be called");
130
+ await assert.rejects(dream.dreamProject(f.slug, { ...f.options, targetChars: 100 }), /Recent event evidence does not fit/);
131
+ assert.deepEqual(await Promise.all(paths.map((name) => fs.readFile(name, "utf8"))), before);
132
+ });
133
+
134
+ test("compaction retries retain source evidence while the caller owns the review marker", async (t) => {
135
+ const f = await fixture(t);
136
+ const id = await f.event("Durable correction", "Keep production reads fail-closed.");
137
+ const source = `events/${id.slice(0, 10)}.md, ${id}`;
138
+ let calls = 0;
139
+ global.fetch = async (_url, request) => {
140
+ const input = JSON.parse(request.body).input;
141
+ calls++;
142
+ if (calls === 1) return respond({ current_state_md: `# Current State\n\n- Keep production reads fail-closed. Source: ${source}.\n\n- ${"Historical detail. ".repeat(200)}`, open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
143
+ assert.ok(input.includes(source));
144
+ assert.match(input, /Preserve the event path and timestamp attached to each retained/);
145
+ assert.doesNotMatch(input, /<!-- hippocamp-dream-through:/);
146
+ const retryTarget = Number(input.match(/Target combined size[^:]*: (\d+) chars/)[1]);
147
+ assert.ok(retryTarget > 0 && retryTarget < f.options.targetChars);
148
+ return respond({ current_state_md: `# Current State\n\n- Keep production reads fail-closed. Source: ${source}.`, open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
149
+ };
150
+ const result = await dream.dreamProject(f.slug, f.options);
151
+ assert.equal(result.compactionPasses, 2);
152
+ const current = await fs.readFile(path.join(f.project, "current_state.md"), "utf8");
153
+ assert.ok(current.includes(source));
154
+ assert.equal((current.match(/<!-- hippocamp-dream-through:/g) || []).length, 1);
155
+ assert.equal((await dream.dreamProject(f.slug, f.options)).skipped, "below_threshold");
156
+ assert.equal(calls, 2);
157
+ });
158
+
159
+ test("recent reads ignore an index path that points outside its canonical event day", async (t) => {
160
+ const f = await fixture(t);
161
+ const id = await f.event("Safe event", "Canonical event evidence.");
162
+ const indexPath = path.join(f.project, "events", `${id.slice(0, 10)}.index.json`);
163
+ const index = JSON.parse(await fs.readFile(indexPath, "utf8"));
164
+ index.path = "../../outside.md";
165
+ await fs.writeFile(path.join(f.root, "outside.md"), `## ${id} — Other\n\nOutside scope.\n`);
166
+ await fs.writeFile(indexPath, JSON.stringify(index));
167
+ const events = await memory.readRecentEvents({ projectRoot: f.projectRoot, after: new Date(Date.now() - 86400000).toISOString() });
168
+ assert.equal(events.length, 1);
169
+ assert.match(events[0].snippet, /Canonical event evidence/);
170
+ assert.doesNotMatch(events[0].snippet, /Outside scope/);
171
+ });
172
+
173
+ for (const failure of ["current_state.md", "open_threads.md", "commit"]) {
174
+ test(`a failed ${failure} save leaves both files and the review marker unchanged`, async (t) => {
175
+ const f = await fixture(t);
176
+ await f.event("Durable correction", "Keep production reads fail-closed.");
177
+ const paths = ["current_state.md", "open_threads.md"].map((name) => path.join(f.project, name));
178
+ const before = await Promise.all(paths.map((name) => fs.readFile(name, "utf8")));
179
+ global.fetch = async () => respond({ current_state_md: "# Current State\n\n- Keep production reads fail-closed.", open_threads_md: "# Open Threads\n\n- Keep unresolved work." });
180
+ const method = failure === "commit" ? "rename" : "writeFile";
181
+ const original = fs[method];
182
+ t.mock.method(fs, method, async (file, ...args) => {
183
+ if (failure === "commit" ? args[0] === paths[0] : path.basename(file) === failure) throw new Error("Fixture write failure");
184
+ return original(file, ...args);
185
+ });
186
+ await assert.rejects(dream.dreamProject(f.slug, f.options), /Fixture write failure/);
187
+ assert.deepEqual(await Promise.all(paths.map((name) => fs.readFile(name, "utf8"))), before);
188
+ assert.equal((await fs.readdir(f.project)).some((name) => name.startsWith(".dream-")), false);
189
+ });
190
+ }
@@ -41,26 +41,26 @@ test("MCP server rejects calls that would succeed but misbehave", async (t) => {
41
41
  assert.equal(eventWrite.isError, true);
42
42
  assert.match(resultText(eventWrite), /append-only/);
43
43
 
44
- const noCues = await client.callTool({
44
+ const noKeywords = await client.callTool({
45
45
  name: "append_event",
46
- arguments: { title: "Missing cues", content: "Unsearchable.", sync: false },
46
+ arguments: { title: "Missing keywords", content: "Unsearchable.", sync: false },
47
47
  });
48
- assert.equal(noCues.isError, true);
48
+ assert.equal(noKeywords.isError, true);
49
49
 
50
- const tooManyCues = await client.callTool({
50
+ const tooManyKeywords = await client.callTool({
51
51
  name: "append_event",
52
52
  arguments: {
53
- title: "Too many cues",
54
- cues: Array.from({ length: 9 }, (_, index) => `cue-${index}`),
53
+ title: "Too many keywords",
54
+ keywords: Array.from({ length: 9 }, (_, index) => `keyword-${index}`),
55
55
  content: "Too broad.",
56
56
  sync: false,
57
57
  },
58
58
  });
59
- assert.equal(tooManyCues.isError, true);
59
+ assert.equal(tooManyKeywords.isError, true);
60
60
 
61
61
  const noTitle = await client.callTool({
62
62
  name: "append_event",
63
- arguments: { title: " ", cues: ["untitled"], content: "No heading.", sync: false },
63
+ arguments: { title: " ", keywords: ["untitled"], content: "No heading.", sync: false },
64
64
  });
65
65
  assert.equal(noTitle.isError, true);
66
66
 
@@ -73,8 +73,8 @@ test("MCP server rejects calls that would succeed but misbehave", async (t) => {
73
73
 
74
74
  const valid = await client.callTool({
75
75
  name: "append_event",
76
- arguments: { title: "Valid event", cues: ["valid-cue"], content: "Indexed.", projectRoot, sync: false },
76
+ arguments: { title: "Valid event", keywords: ["valid-keyword"], content: "Indexed.", projectRoot, sync: false },
77
77
  });
78
78
  assert.notEqual(valid.isError, true);
79
- assert.deepEqual(JSON.parse(resultText(valid)).cues, ["valid-cue"]);
79
+ assert.deepEqual(JSON.parse(resultText(valid)).keywords, ["valid-keyword"]);
80
80
  });
@@ -76,7 +76,6 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
76
76
  const projectRoot = path.join(tempRoot, "search-project");
77
77
 
78
78
  process.env.HIPPOCAMP_GLOBAL_ROOT = lagoonRoot;
79
- await fs.mkdir(projectRoot, { recursive: true });
80
79
  t.after(async () => {
81
80
  if (originalRoot === undefined) {
82
81
  delete process.env.HIPPOCAMP_GLOBAL_ROOT;
@@ -88,7 +87,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
88
87
 
89
88
  await memory.appendEvent({
90
89
  content: "Decision:\nUse indexed search only.",
91
- cues: ["strict-index"],
90
+ keywords: ["strict-index"],
92
91
  date: "2026-09-21",
93
92
  projectRoot,
94
93
  scope: "project",
@@ -109,7 +108,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
109
108
 
110
109
  await memory.appendEvent({
111
110
  content: `Decision:\n${"x".repeat(1300)}`,
112
- cues: ["oversized-metadata"],
111
+ keywords: ["oversized-metadata"],
113
112
  date: "2026-09-21",
114
113
  projectRoot,
115
114
  scope: "project",
@@ -124,7 +123,9 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
124
123
  scope: "project",
125
124
  });
126
125
 
127
- assert.deepEqual(oversized.results, []);
126
+ // A keyword-only match still returns whole paragraphs; the oversized run is left out, never cropped.
127
+ assert.equal(oversized.results[0]?.heading, "Large metadata-only match");
128
+ assert.equal(oversized.results[0].snippet, "Decision:");
128
129
 
129
130
  const projectMemoryRoot = path.join(lagoonRoot, "projects", "search-project");
130
131
  await fs.writeFile(
@@ -145,7 +146,7 @@ test("search uses indexed events and returns coherent evidence", async (t) => {
145
146
  const eventsRoot = path.join(projectMemoryRoot, "events");
146
147
  await fs.writeFile(
147
148
  path.join(eventsRoot, "2026-09-20.md"),
148
- "# Events: 2026-09-20\n\n## 09:00 — Hidden fallback\n\nCues:\n- quasar-velvet\n\nShould not be scanned.\n",
149
+ "# Events: 2026-09-20\n\n## 09:00 — Hidden fallback\n\nKeywords:\n- quasar-velvet\n\nShould not be scanned.\n",
149
150
  "utf8",
150
151
  );
151
152
 
@@ -174,11 +175,11 @@ test("search ranks indexed event bodies with typo tolerance", async (t) => {
174
175
  await fs.rm(tempRoot, { recursive: true, force: true });
175
176
  });
176
177
 
177
- for (const [title, cues, content] of [
178
+ for (const [title, keywords, content] of [
178
179
  ["Deploy note", ["deploy"], "The zanzibar migration needs a rollback plan."],
179
180
  ["Unrelated note", ["billing"], "Invoices are generated monthly."],
180
181
  ]) {
181
- await memory.appendEvent({ content, cues, projectRoot, scope: "project", sync: false, title });
182
+ await memory.appendEvent({ content, keywords, projectRoot, scope: "project", sync: false, title });
182
183
  }
183
184
 
184
185
  const search = await memory.searchMemory({ projectRoot, query: "zanzibr rollback", scope: "project" });
@@ -188,6 +189,42 @@ test("search ranks indexed event bodies with typo tolerance", async (t) => {
188
189
  assert.equal(search.results[0].match, "body");
189
190
  });
190
191
 
192
+ test("search still matches events written with legacy Cues sections and indexes", async (t) => {
193
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
194
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-legacy-cues-"));
195
+ const projectRoot = path.join(tempRoot, "legacy-project");
196
+ const eventsRoot = path.join(tempRoot, "lagoon", "projects", "legacy-project", "events");
197
+
198
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
199
+ await fs.mkdir(eventsRoot, { recursive: true });
200
+ t.after(async () => {
201
+ if (originalRoot === undefined) {
202
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
203
+ } else {
204
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
205
+ }
206
+ await fs.rm(tempRoot, { recursive: true, force: true });
207
+ });
208
+
209
+ const id = "2026-09-01T09:00:00.000Z";
210
+ await fs.writeFile(
211
+ path.join(eventsRoot, "2026-09-01.md"),
212
+ `# Events — 2026-09-01\n\n## ${id} — Old note\n\nCues:\n- quasar-velvet\n\nOld body.\n`,
213
+ "utf8",
214
+ );
215
+ await fs.writeFile(
216
+ path.join(eventsRoot, "2026-09-01.index.json"),
217
+ JSON.stringify({ version: 1, path: "2026-09-01.md", events: [{ id, heading: "Old note", cues: ["quasar-velvet"] }] }),
218
+ "utf8",
219
+ );
220
+
221
+ const search = await memory.searchMemory({ projectRoot, query: "quasar velvet", scope: "project" });
222
+
223
+ assert.equal(search.results.length, 1);
224
+ assert.deepEqual(search.results[0].keywords, ["quasar-velvet"]);
225
+ assert.equal(search.results[0].match, "keywords");
226
+ });
227
+
191
228
  test("search returns whole sentences from an oversized matching paragraph", async (t) => {
192
229
  const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
193
230
  const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-long-paragraph-"));
@@ -207,7 +244,7 @@ test("search returns whole sentences from an oversized matching paragraph", asyn
207
244
  const filler = "The weekly sync covered routine updates. ".repeat(40);
208
245
  await memory.appendEvent({
209
246
  content: `${filler}The kestrel database moved to eu-west-3. ${filler}`,
210
- cues: ["infra"],
247
+ keywords: ["infra"],
211
248
  projectRoot,
212
249
  scope: "project",
213
250
  sync: false,
@@ -246,7 +283,7 @@ test("search returns every matching paragraph that fits the excerpt budget", asy
246
283
  ];
247
284
  await memory.appendEvent({
248
285
  content: paragraphs.join("\n\n"),
249
- cues: ["infra"],
286
+ keywords: ["infra"],
250
287
  projectRoot,
251
288
  scope: "project",
252
289
  sync: false,
@@ -282,7 +319,7 @@ test("events appended in the same millisecond keep distinct ids and bodies", asy
282
319
  const timestamp = "2026-10-01T09:00:00.000Z";
283
320
  const first = await memory.appendEvent({
284
321
  content: "The zanzibar migration needs a rollback plan.",
285
- cues: ["deploy"],
322
+ keywords: ["deploy"],
286
323
  projectRoot,
287
324
  scope: "project",
288
325
  sync: false,
@@ -291,7 +328,7 @@ test("events appended in the same millisecond keep distinct ids and bodies", asy
291
328
  });
292
329
  const second = await memory.appendEvent({
293
330
  content: "Invoices are generated monthly.",
294
- cues: ["billing"],
331
+ keywords: ["billing"],
295
332
  projectRoot,
296
333
  scope: "project",
297
334
  sync: false,
@@ -329,7 +366,7 @@ test("parallel appends keep every event", async (t) => {
329
366
  Array.from({ length: 20 }, (_, index) =>
330
367
  memory.appendEvent({
331
368
  content: `Parallel event ${index}.`,
332
- cues: ["parallel"],
369
+ keywords: ["parallel"],
333
370
  date: "2026-10-01",
334
371
  projectRoot,
335
372
  scope: "project",
@@ -366,7 +403,7 @@ test("search favors events from a month named in the query", async (t) => {
366
403
  for (const timestamp of ["2026-05-02T08:00:00.000Z", "2026-08-05T10:00:00.000Z", "2026-09-01T09:00:00.000Z"]) {
367
404
  await memory.appendEvent({
368
405
  content: "Published the Android build to Google Play production.",
369
- cues: ["android-release"],
406
+ keywords: ["android-release"],
370
407
  date: timestamp.slice(0, 10),
371
408
  projectRoot,
372
409
  scope: "project",
@@ -407,7 +444,7 @@ test("search favors events from a relative date named in the query", async (t) =
407
444
  await memory.appendEvent({
408
445
  // The old event matches the words better, so only the date words can rank the others first.
409
446
  content: key === "older" ? "Deploy key rotation: rotated the deploy key again." : "Rotated the key for staging.",
410
- cues: ["deploy-key"],
447
+ keywords: ["deploy-key"],
411
448
  date: timestamp.slice(0, 10),
412
449
  projectRoot,
413
450
  scope: "project",
@@ -448,7 +485,7 @@ test("search returns a snippet for a long event matched only by its date", async
448
485
 
449
486
  await memory.appendEvent({
450
487
  content: Array(30).fill("The weekly sync covered routine updates.").join("\n\n"),
451
- cues: ["sync"],
488
+ keywords: ["sync"],
452
489
  date: "2026-08-05",
453
490
  projectRoot,
454
491
  scope: "project",
@@ -485,7 +522,7 @@ test("search returns nothing when no memory is about the question", async (t) =>
485
522
  ["Deploy rollback", "A failed backend deploy rolls back to the previous revision."],
486
523
  ["Billing note", "Invoices are generated monthly."],
487
524
  ]) {
488
- await memory.appendEvent({ content, cues: ["ops"], projectRoot, scope: "project", sync: false, title });
525
+ await memory.appendEvent({ content, keywords: ["ops"], projectRoot, scope: "project", sync: false, title });
489
526
  }
490
527
 
491
528
  const unrelated = await memory.searchMemory({ projectRoot, query: "which kubernetes namespace does the backend deploy to", scope: "project" });
@@ -498,3 +535,130 @@ test("search returns nothing when no memory is about the question", async (t) =>
498
535
  // No event is from August; the month name must not count as a missing topic word.
499
536
  assert.ok(monthOnly.results.length > 0);
500
537
  });
538
+
539
+ test("search keeps an event matched only by a keyword its long body never repeats", async (t) => {
540
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
541
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-keyword-only-"));
542
+ const projectRoot = path.join(tempRoot, "keyword-only-project");
543
+
544
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
545
+ await fs.mkdir(projectRoot, { recursive: true });
546
+ t.after(async () => {
547
+ if (originalRoot === undefined) {
548
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
549
+ } else {
550
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
551
+ }
552
+ await fs.rm(tempRoot, { recursive: true, force: true });
553
+ });
554
+
555
+ const content = [
556
+ "Opened three hardening PRs after the security audit.",
557
+ "The backend PR rotates the cron secret and checks request signatures on every scheduled job. ".repeat(8),
558
+ "The website PR updates the content studio dependencies and removes an unused preview route. ".repeat(8),
559
+ ].join("\n\n");
560
+
561
+ assert.ok(content.length > 1200);
562
+ await memory.appendEvent({
563
+ content,
564
+ keywords: ["security-audit", "strava-oauth"],
565
+ projectRoot,
566
+ scope: "project",
567
+ sync: false,
568
+ title: "Security audit opened hardening PRs",
569
+ });
570
+
571
+ const search = await memory.searchMemory({ projectRoot, query: "Strava bug", scope: "project" });
572
+
573
+ assert.equal(search.results[0]?.heading, "Security audit opened hardening PRs");
574
+ assert.match(search.results[0].snippet, /^Opened three hardening PRs/);
575
+ });
576
+
577
+ test("search returns nothing when a rare word only resembles memory words", async (t) => {
578
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
579
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-lookalike-"));
580
+ const projectRoot = path.join(tempRoot, "lookalike-project");
581
+
582
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
583
+ await fs.mkdir(projectRoot, { recursive: true });
584
+ t.after(async () => {
585
+ if (originalRoot === undefined) {
586
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
587
+ } else {
588
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
589
+ }
590
+ await fs.rm(tempRoot, { recursive: true, force: true });
591
+ });
592
+
593
+ await memory.appendEvent({
594
+ content: "Each worker instance reads the instruct prompt and its content before a sync.",
595
+ keywords: ["health-sync"],
596
+ projectRoot,
597
+ scope: "project",
598
+ sync: false,
599
+ title: "Apple Health sync banner hardening",
600
+ });
601
+
602
+ // "instinct" is one typo away from "instance" and "instruct" but means something else.
603
+ const lookalike = await memory.searchMemory({ projectRoot, query: "Instinct", scope: "project" });
604
+ const shared = await memory.searchMemory({ projectRoot, query: "context", scope: "project" });
605
+ const plural = await memory.searchMemory({ projectRoot, query: "worker instances", scope: "project" });
606
+
607
+ assert.deepEqual(lookalike.results, []);
608
+ assert.deepEqual(shared.results, []);
609
+ assert.equal(plural.results[0]?.heading, "Apple Health sync banner hardening");
610
+ });
611
+
612
+ test("an empty folder has no project and uses global memory", async (t) => {
613
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
614
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-empty-folder-"));
615
+ const lagoonRoot = path.join(tempRoot, "lagoon");
616
+ const emptyRoot = path.join(tempRoot, "billowy-glove");
617
+ const codeRoot = path.join(tempRoot, "job-search");
618
+
619
+ process.env.HIPPOCAMP_GLOBAL_ROOT = lagoonRoot;
620
+ // Only .git, like a fresh session folder some agents create.
621
+ await fs.mkdir(path.join(emptyRoot, ".git"), { recursive: true });
622
+ await fs.mkdir(codeRoot, { recursive: true });
623
+ await fs.writeFile(path.join(codeRoot, "notes.md"), "Job search notes.\n", "utf8");
624
+ t.after(async () => {
625
+ if (originalRoot === undefined) {
626
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
627
+ } else {
628
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
629
+ }
630
+ await fs.rm(tempRoot, { recursive: true, force: true });
631
+ });
632
+
633
+ const wake = await memory.wakeUp({ projectRoot: emptyRoot });
634
+ const event = await memory.appendEvent({
635
+ content: "Created the applying-to-jobs skill.",
636
+ keywords: ["applying-to-jobs"],
637
+ projectRoot: emptyRoot,
638
+ scope: "project",
639
+ sync: false,
640
+ title: "Job application skill created",
641
+ });
642
+ await memory.appendEvent({
643
+ content: "Created the applying-to-jobs tracker in Notion.",
644
+ keywords: ["applying-to-jobs-tracker"],
645
+ projectRoot: codeRoot,
646
+ scope: "project",
647
+ sync: false,
648
+ title: "Job tracker created",
649
+ });
650
+ const fromEmpty = await memory.searchMemory({ projectRoot: emptyRoot, query: "applying to jobs skill" });
651
+ const projectScope = await memory.searchMemory({ projectRoot: emptyRoot, query: "applying to jobs", scope: "project" });
652
+ const fromProject = await memory.searchMemory({ projectRoot: codeRoot, query: "applying to jobs skill" });
653
+
654
+ assert.equal(wake.projectSlug, null);
655
+ assert.equal(wake.projectMemoryRoot, lagoonRoot);
656
+ assert.equal(path.dirname(path.dirname(event.indexPath)), lagoonRoot);
657
+ // Global memory is searched once, and other projects stay out of it.
658
+ assert.deepEqual(fromEmpty.results.map((result) => result.heading), ["Job application skill created"]);
659
+ assert.deepEqual(projectScope.results.map((result) => result.heading), ["Job application skill created"]);
660
+ assert.equal(fromProject.results[0]?.heading, "Job application skill created");
661
+ assert.equal(memory.getProjectSlug(codeRoot), "job-search");
662
+ // A folder that does not exist keeps its name, as Dream's synthetic roots do.
663
+ assert.equal(memory.getProjectSlug(path.join(tempRoot, "missing")), "missing");
664
+ });
@@ -29,7 +29,6 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
29
29
  });
30
30
 
31
31
  await fs.mkdir(realLagoon);
32
- await fs.mkdir(projectRoot);
33
32
  await fs.symlink(realLagoon, linkedLagoon);
34
33
  git(["init", "-q", "-b", "main"]);
35
34
  git(["commit", "-q", "--allow-empty", "-m", "init"]);
@@ -45,7 +44,7 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
45
44
 
46
45
  const result = await memory.appendEvent({
47
46
  content: "Use advisory locks.",
48
- cues: ["locks"],
47
+ keywords: ["locks"],
49
48
  projectRoot,
50
49
  scope: "project",
51
50
  title: "Queue decision",
@@ -56,12 +55,11 @@ test("sync commits when the Lagoon path goes through a symlink", async (t) => {
56
55
 
57
56
  // A symlink inside the Lagoon pointing at another project must not be synced as this one.
58
57
  const otherProject = path.join(tempRoot, "other-project");
59
- await fs.mkdir(otherProject);
60
58
  await fs.mkdir(path.join(realLagoon, "projects", "other-project"), { recursive: true });
61
59
  await fs.symlink(path.join(realLagoon, "projects", "linked-project", "events"), path.join(realLagoon, "projects", "other-project", "events"));
62
60
 
63
61
  await assert.rejects(
64
- memory.appendEvent({ content: "Cross write.", cues: ["x"], projectRoot: otherProject, scope: "project", title: "Escape" }),
62
+ memory.appendEvent({ content: "Cross write.", keywords: ["x"], projectRoot: otherProject, scope: "project", title: "Escape" }),
65
63
  /escapes the memory root/,
66
64
  );
67
65
  });