hippocamp 0.2.1 → 0.2.3

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.1",
3
+ "version": "0.2.3",
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. 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.",
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
 
@@ -745,6 +747,7 @@ function createQueryInfo(query) {
745
747
  normalized: tokens.join(" "),
746
748
  cue: tokens.join("-"),
747
749
  tokens,
750
+ datePrefixes: queryDatePrefixes(original),
748
751
  };
749
752
  }
750
753
 
@@ -893,7 +896,7 @@ function splitOversizedBlock(block) {
893
896
  return chunks;
894
897
  }
895
898
 
896
- function createSearchExcerpt(content, queryInfo) {
899
+ function createSearchExcerpt(content, queryInfo, leadWhenUnmatched = false) {
897
900
  const trimmed = content.trim();
898
901
 
899
902
  if (!trimmed) {
@@ -924,8 +927,25 @@ function createSearchExcerpt(content, queryInfo) {
924
927
 
925
928
  index.addAll(blocks.map((text, position) => ({ position, text })));
926
929
 
927
- const [best] = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE });
928
- return best ? blocks[best.id] : null;
930
+ const picked = [];
931
+ let length = 0;
932
+
933
+ // Keep the best-matching paragraphs that fit the excerpt budget, in file order.
934
+ const hits = index.search(queryInfo.normalized, { fuzzy: SEARCH_FUZZY_DISTANCE }).map((hit) => hit.id);
935
+
936
+ // An event chosen by its date may share no words with the query; then lead with its first paragraphs.
937
+ for (const id of hits.length || !leadWhenUnmatched ? hits : blocks.map((_, position) => position)) {
938
+ const added = blocks[id].length + (picked.length ? 2 : 0);
939
+
940
+ if (length + added > SEARCH_EXCERPT_MAX_CHARS) {
941
+ continue;
942
+ }
943
+
944
+ picked.push(id);
945
+ length += added;
946
+ }
947
+
948
+ return picked.length ? picked.sort((left, right) => left - right).map((id) => blocks[id]).join("\n\n") : null;
929
949
  }
930
950
 
931
951
  async function readMemoryFile({ scope, path: relativePath, projectRoot }) {
@@ -976,6 +996,39 @@ async function writeMemoryFile({ scope, path: relativePath, content, projectRoot
976
996
  };
977
997
  }
978
998
 
999
+ // Serialize read-modify-write of one event file across processes. The lock lives
1000
+ // in the temp directory so a crashed writer never leaves it in the Lagoon repo.
1001
+ async function withEventFileLock(filePath, action) {
1002
+ const lockName = crypto.createHash("sha1").update(filePath).digest("hex");
1003
+ const lockPath = path.join(os.tmpdir(), `hippocamp-event-${lockName}.lock`);
1004
+
1005
+ for (;;) {
1006
+ try {
1007
+ await (await fs.open(lockPath, "wx")).close();
1008
+ break;
1009
+ } catch (error) {
1010
+ if (error.code !== "EEXIST") {
1011
+ throw error;
1012
+ }
1013
+
1014
+ const stats = await statIfExists(lockPath);
1015
+
1016
+ if (stats && Date.now() - stats.mtimeMs > EVENT_LOCK_STALE_MS) {
1017
+ await fs.rm(lockPath, { force: true });
1018
+ continue;
1019
+ }
1020
+
1021
+ await new Promise((resolve) => setTimeout(resolve, EVENT_LOCK_RETRY_MS));
1022
+ }
1023
+ }
1024
+
1025
+ try {
1026
+ return await action();
1027
+ } finally {
1028
+ await fs.rm(lockPath, { force: true });
1029
+ }
1030
+ }
1031
+
979
1032
  async function appendEvent({
980
1033
  scope = "project",
981
1034
  content,
@@ -998,18 +1051,30 @@ async function appendEvent({
998
1051
  : body.trim();
999
1052
  const attributionSection = formatAttributionSection(attribution);
1000
1053
  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
1054
  const relativePath = `events/${date}.md`;
1004
1055
  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
1056
 
1010
1057
  await ensureParentDirectory(target.absolutePath);
1011
- await fs.writeFile(target.absolutePath, nextContent, "utf8");
1012
- const indexPath = await writeEventIndex(target.absolutePath, target.path);
1058
+
1059
+ const { eventBlock, indexPath } = await withEventFileLock(target.absolutePath, async () => {
1060
+ const existing = await readFileIfExists(target.absolutePath);
1061
+ const takenIds = new Set(parseEventBlocks(existing || "").map((event) => event.id));
1062
+
1063
+ // The timestamp is the event id, and search matches index entries to blocks by id.
1064
+ while (takenIds.has(timestamp) && !Number.isNaN(Date.parse(timestamp))) {
1065
+ timestamp = new Date(Date.parse(timestamp) + 1).toISOString();
1066
+ }
1067
+
1068
+ const heading = title ? `## ${timestamp} — ${title.trim()}` : `## ${timestamp}`;
1069
+ const block = `${heading}\n\n${eventBody}\n`;
1070
+ const nextContent = existing?.trim()
1071
+ ? `${existing.trimEnd()}\n\n${block.trimEnd()}\n`
1072
+ : `# Events — ${date}\n\n${block.trimEnd()}\n`;
1073
+
1074
+ await fs.writeFile(target.absolutePath, nextContent, "utf8");
1075
+
1076
+ return { eventBlock: block, indexPath: await writeEventIndex(target.absolutePath, target.path) };
1077
+ });
1013
1078
 
1014
1079
  const syncResult = sync
1015
1080
  ? await syncMemory({
@@ -1153,9 +1218,47 @@ async function collectMarkdownDocuments({ scope, root, curatedFiles }) {
1153
1218
  };
1154
1219
  }
1155
1220
 
1221
+ const MONTH_NAMES = "january february march april may june july august september october november december".split(" ");
1222
+
1223
+ // Search word for a UTC day ("2026-08-05" -> "d20260805") or month ("2026-08" -> "august").
1224
+ // A day is one exact word, so it never matches a neighbor day by typo tolerance.
1225
+ function dateTerm(prefix) {
1226
+ return prefix.length === 10 ? `d${prefix.replace(/-/g, "")}` : MONTH_NAMES[Number(prefix.slice(5, 7)) - 1];
1227
+ }
1228
+
1229
+ // Month and day words of an event's timestamp id.
1230
+ function eventDateText(id) {
1231
+ return /^\d{4}-\d{2}-\d{2}T/.test(id || "") ? `${dateTerm(id.slice(0, 7))} ${dateTerm(id.slice(0, 10))}` : "";
1232
+ }
1233
+
1234
+ // Dates a query asks for: YYYY-MM-DD dates and relative phrases, as UTC day
1235
+ // ("2026-08-05") or month ("2026-08") prefixes of event ids. Weeks start on Monday.
1236
+ function queryDatePrefixes(query, now = new Date()) {
1237
+ const text = query.toLowerCase();
1238
+ const prefixes = [...text.matchAll(/\b\d{4}-\d{2}-\d{2}\b/g)].map((match) => match[0]);
1239
+ const utc = (months, days) =>
1240
+ new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + months, days)).toISOString();
1241
+ const addDays = (from, to) => {
1242
+ for (let offset = from; offset <= to; offset += 1) {
1243
+ prefixes.push(utc(0, now.getUTCDate() + offset).slice(0, 10));
1244
+ }
1245
+ };
1246
+ const addMonth = (offset) => prefixes.push(utc(offset, 1).slice(0, 7));
1247
+ const monday = -((now.getUTCDay() + 6) % 7);
1248
+
1249
+ if (/\btoday\b/.test(text)) addDays(0, 0);
1250
+ if (/\byesterday\b/.test(text)) addDays(-1, -1);
1251
+ if (/\bthis week\b/.test(text)) addDays(monday, 0);
1252
+ if (/\blast week\b/.test(text)) addDays(monday - 7, monday - 1);
1253
+ if (/\bthis month\b/.test(text)) addMonth(0);
1254
+ if (/\blast month\b/.test(text)) addMonth(-1);
1255
+
1256
+ return prefixes;
1257
+ }
1258
+
1156
1259
  function buildSearchIndex(documents) {
1157
1260
  const index = new MiniSearch({
1158
- fields: ["cues", "heading", "body"],
1261
+ fields: ["cues", "heading", "body", "date"],
1159
1262
  idField: "position",
1160
1263
  tokenize: tokenizeSearchText,
1161
1264
  processTerm: (term) => term,
@@ -1167,6 +1270,7 @@ function buildSearchIndex(documents) {
1167
1270
  cues: (document.cues || []).join(" "),
1168
1271
  heading: document.heading || document.path,
1169
1272
  body: document.body,
1273
+ date: eventDateText(document.id),
1170
1274
  })),
1171
1275
  );
1172
1276
 
@@ -1174,10 +1278,16 @@ function buildSearchIndex(documents) {
1174
1278
  }
1175
1279
 
1176
1280
  function rankSearchDocuments(queryInfo, { index, documents }) {
1281
+ const { datePrefixes } = queryInfo;
1282
+
1283
+ // A date the query asks for filters events to those dates. Undated files always pass.
1177
1284
  return index
1178
- .search(queryInfo.normalized, {
1285
+ .search([queryInfo.normalized, ...datePrefixes.map(dateTerm)].join(" "), {
1179
1286
  boost: SEARCH_FIELD_BOOST,
1180
- fuzzy: SEARCH_FUZZY_DISTANCE,
1287
+ fuzzy: (term) => (/^d\d{8}$/.test(term) ? 0 : SEARCH_FUZZY_DISTANCE),
1288
+ filter: datePrefixes.length
1289
+ ? (hit) => !documents[hit.id].id || datePrefixes.some((prefix) => documents[hit.id].id.startsWith(prefix))
1290
+ : undefined,
1181
1291
  })
1182
1292
  .slice(0, SEARCH_CANDIDATE_LIMIT)
1183
1293
  .map((hit) => ({
@@ -1187,7 +1297,7 @@ function rankSearchDocuments(queryInfo, { index, documents }) {
1187
1297
  }
1188
1298
 
1189
1299
  function toSearchResult(queryInfo, { document, score }) {
1190
- const snippet = createSearchExcerpt(document.body, queryInfo);
1300
+ const snippet = createSearchExcerpt(document.body, queryInfo, queryInfo.datePrefixes.length > 0);
1191
1301
 
1192
1302
  if (!snippet) {
1193
1303
  return null;
@@ -221,3 +221,245 @@ 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
+ });
349
+
350
+ test("search favors events from a month named in the query", async (t) => {
351
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
352
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-month-search-"));
353
+ const projectRoot = path.join(tempRoot, "month-project");
354
+
355
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
356
+ await fs.mkdir(projectRoot, { recursive: true });
357
+ t.after(async () => {
358
+ if (originalRoot === undefined) {
359
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
360
+ } else {
361
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
362
+ }
363
+ await fs.rm(tempRoot, { recursive: true, force: true });
364
+ });
365
+
366
+ for (const timestamp of ["2026-05-02T08:00:00.000Z", "2026-08-05T10:00:00.000Z", "2026-09-01T09:00:00.000Z"]) {
367
+ await memory.appendEvent({
368
+ content: "Published the Android build to Google Play production.",
369
+ cues: ["android-release"],
370
+ date: timestamp.slice(0, 10),
371
+ projectRoot,
372
+ scope: "project",
373
+ sync: false,
374
+ timestamp,
375
+ title: "Android release",
376
+ });
377
+ }
378
+
379
+ const byMonth = await memory.searchMemory({ projectRoot, query: "android build in august", scope: "project" });
380
+ const byDay = await memory.searchMemory({ projectRoot, query: "android build 2026-05-02", scope: "project" });
381
+
382
+ assert.equal(byMonth.results[0].id, "2026-08-05T10:00:00.000Z");
383
+ assert.equal(byDay.results[0].id, "2026-05-02T08:00:00.000Z");
384
+ });
385
+
386
+ test("search favors events from a relative date named in the query", async (t) => {
387
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
388
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-relative-date-"));
389
+ const projectRoot = path.join(tempRoot, "relative-project");
390
+
391
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
392
+ await fs.mkdir(projectRoot, { recursive: true });
393
+ t.after(async () => {
394
+ if (originalRoot === undefined) {
395
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
396
+ } else {
397
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
398
+ }
399
+ await fs.rm(tempRoot, { recursive: true, force: true });
400
+ });
401
+
402
+ // Seven days ago is always in last calendar week; one day ago is yesterday.
403
+ const daysAgo = (days) => new Date(Date.now() - days * 86_400_000).toISOString();
404
+ const timestamps = { yesterday: daysAgo(1), lastWeek: daysAgo(7), older: daysAgo(30) };
405
+
406
+ for (const [key, timestamp] of Object.entries(timestamps)) {
407
+ await memory.appendEvent({
408
+ // The old event matches the words better, so only the date words can rank the others first.
409
+ content: key === "older" ? "Deploy key rotation: rotated the deploy key again." : "Rotated the key for staging.",
410
+ cues: ["deploy-key"],
411
+ date: timestamp.slice(0, 10),
412
+ projectRoot,
413
+ scope: "project",
414
+ sync: false,
415
+ timestamp,
416
+ title: "Deploy key rotation",
417
+ });
418
+ }
419
+
420
+ const ids = async (phrase) =>
421
+ (await memory.searchMemory({ projectRoot, query: `deploy key rotation ${phrase}`, scope: "project" })).results.map(
422
+ (result) => result.id,
423
+ );
424
+
425
+ assert.deepEqual(await ids("yesterday"), [timestamps.yesterday]);
426
+ // On a Monday, yesterday is also in last week.
427
+ const lastWeek = await ids("last week");
428
+ assert.ok(lastWeek.includes(timestamps.lastWeek));
429
+ assert.ok(!lastWeek.includes(timestamps.older));
430
+ assert.deepEqual(await ids("today"), []);
431
+ });
432
+
433
+ test("search returns a snippet for a long event matched only by its date", async (t) => {
434
+ const originalRoot = process.env.HIPPOCAMP_GLOBAL_ROOT;
435
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "hippocamp-date-only-"));
436
+ const projectRoot = path.join(tempRoot, "date-only-project");
437
+
438
+ process.env.HIPPOCAMP_GLOBAL_ROOT = path.join(tempRoot, "lagoon");
439
+ await fs.mkdir(projectRoot, { recursive: true });
440
+ t.after(async () => {
441
+ if (originalRoot === undefined) {
442
+ delete process.env.HIPPOCAMP_GLOBAL_ROOT;
443
+ } else {
444
+ process.env.HIPPOCAMP_GLOBAL_ROOT = originalRoot;
445
+ }
446
+ await fs.rm(tempRoot, { recursive: true, force: true });
447
+ });
448
+
449
+ await memory.appendEvent({
450
+ content: Array(30).fill("The weekly sync covered routine updates.").join("\n\n"),
451
+ cues: ["sync"],
452
+ date: "2026-08-05",
453
+ projectRoot,
454
+ scope: "project",
455
+ sync: false,
456
+ timestamp: "2026-08-05T10:00:00.000Z",
457
+ title: "Weekly sync",
458
+ });
459
+
460
+ const search = await memory.searchMemory({ projectRoot, query: "2026-08-05", scope: "project" });
461
+
462
+ assert.equal(search.results.length, 1);
463
+ assert.match(search.results[0].snippet, /^The weekly sync covered routine updates\./);
464
+ assert.ok(search.results[0].snippet.length <= 1200);
465
+ });