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.
- 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 +125 -15
- package/test/memory-efficiency.test.cjs +242 -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.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
|
|
928
|
-
|
|
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
|
-
|
|
1012
|
-
const indexPath = await
|
|
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
|
+
});
|