hippocamp 0.2.1 → 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.
- package/.github/workflows/ci.yml +1 -0
- package/AGENTS.md +2 -1
- package/README.md +4 -1
- package/package.json +2 -1
- package/scripts/eval-lagoon.cjs +135 -0
- package/scripts/eval-retrieval.cjs +50 -3
- package/scripts/hippocamp-mcp.cjs +1 -1
- package/scripts/hippocamp-memory.cjs +71 -10
- package/test/memory-efficiency.test.cjs +125 -0
package/.github/workflows/ci.yml
CHANGED
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.
|
|
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
|
|
928
|
-
|
|
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
|
-
|
|
1012
|
-
const indexPath = await
|
|
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({
|
|
@@ -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
|
+
});
|