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