@ghilteras/opencode-agent-memory 1.2.0 → 2.0.1
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/README.md +15 -9
- package/package.json +3 -4
- package/src/journal.ts +71 -127
- package/src/tools.ts +3 -3
- package/src/doctor.test.ts +0 -183
- package/src/embeddings.test.ts +0 -47
- package/src/journal.test.ts +0 -634
- package/src/plugin-disabled.test.ts +0 -39
- package/src/plugin.test.ts +0 -220
- package/src/test-suite.test.ts +0 -15
- package/src/tools.test.ts +0 -115
package/src/embeddings.test.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
EMBEDDING_DIMENSION,
|
|
5
|
-
cosineSimilarity,
|
|
6
|
-
embeddingModelName,
|
|
7
|
-
getEmbeddingDimension,
|
|
8
|
-
} from "./embeddings";
|
|
9
|
-
|
|
10
|
-
describe("embeddings", () => {
|
|
11
|
-
test("getEmbeddingDimension returns 384", () => {
|
|
12
|
-
expect(getEmbeddingDimension()).toBe(384);
|
|
13
|
-
expect(EMBEDDING_DIMENSION).toBe(384);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("embeddingModelName returns the multilingual model", () => {
|
|
17
|
-
expect(embeddingModelName()).toBe(
|
|
18
|
-
"Xenova/paraphrase-multilingual-MiniLM-L12-v2",
|
|
19
|
-
);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("cosineSimilarity throws on dimension mismatch", () => {
|
|
23
|
-
expect(() => cosineSimilarity([1, 2, 3], [1, 2, 3, 4])).toThrow(
|
|
24
|
-
/dimension mismatch/i,
|
|
25
|
-
);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("versioned embedding round-trip serializes and parses back", () => {
|
|
29
|
-
const vector = Array.from(
|
|
30
|
-
{ length: EMBEDDING_DIMENSION },
|
|
31
|
-
(_, i) => Math.sin(i),
|
|
32
|
-
);
|
|
33
|
-
const serialized = JSON.stringify({
|
|
34
|
-
v: 2,
|
|
35
|
-
model: embeddingModelName(),
|
|
36
|
-
dimension: EMBEDDING_DIMENSION,
|
|
37
|
-
vector,
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const parsed = JSON.parse(serialized);
|
|
41
|
-
expect(parsed.v).toBe(2);
|
|
42
|
-
expect(parsed.model).toBe(embeddingModelName());
|
|
43
|
-
expect(parsed.dimension).toBe(EMBEDDING_DIMENSION);
|
|
44
|
-
expect(parsed.vector).toEqual(vector);
|
|
45
|
-
expect(parsed.vector.length).toBe(EMBEDDING_DIMENSION);
|
|
46
|
-
});
|
|
47
|
-
});
|
package/src/journal.test.ts
DELETED
|
@@ -1,634 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import * as realEmbeddings from "./embeddings";
|
|
5
|
-
|
|
6
|
-
// Mock the embeddings module to avoid downloading a real model in tests.
|
|
7
|
-
// Spread the real module so every export not overridden here keeps its real
|
|
8
|
-
// value regardless of test-file discovery order.
|
|
9
|
-
mock.module("./embeddings", () => ({
|
|
10
|
-
...realEmbeddings,
|
|
11
|
-
generateEmbedding: async (text: string) => {
|
|
12
|
-
// Deterministic fake embedding based on text content
|
|
13
|
-
const hash = Array.from(text).reduce(
|
|
14
|
-
(acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
|
|
15
|
-
0,
|
|
16
|
-
);
|
|
17
|
-
return Array.from({ length: 8 }, (_, i) => Math.sin(hash + i));
|
|
18
|
-
},
|
|
19
|
-
EMBEDDING_DIMENSION: 8,
|
|
20
|
-
embeddingModelName: () => "test-model",
|
|
21
|
-
cosineSimilarity: (a: number[], b: number[]) => {
|
|
22
|
-
if (a.length !== b.length)
|
|
23
|
-
throw new Error(
|
|
24
|
-
`Embedding dimension mismatch: ${a.length} vs ${b.length}`,
|
|
25
|
-
);
|
|
26
|
-
let dot = 0;
|
|
27
|
-
let normA = 0;
|
|
28
|
-
let normB = 0;
|
|
29
|
-
for (let i = 0; i < a.length; i++) {
|
|
30
|
-
const ai = a[i]!;
|
|
31
|
-
const bi = b[i]!;
|
|
32
|
-
dot += ai * bi;
|
|
33
|
-
normA += ai * ai;
|
|
34
|
-
normB += bi * bi;
|
|
35
|
-
}
|
|
36
|
-
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
37
|
-
return denom === 0 ? 0 : dot / denom;
|
|
38
|
-
},
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
async function mkTmpDir(): Promise<string> {
|
|
42
|
-
return fs.mkdtemp(path.join("/tmp/", "opencode-journal-"));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Bind the journal module AFTER the embeddings mock registration (above) via
|
|
46
|
-
// dynamic import, so the store uses the fake embeddings regardless of bun's
|
|
47
|
-
// module-mock rewiring semantics; a static import would be hoisted above it.
|
|
48
|
-
let createJournalStore!: (typeof import("./journal"))["createJournalStore"];
|
|
49
|
-
let loadConfig!: (typeof import("./journal"))["loadConfig"];
|
|
50
|
-
|
|
51
|
-
beforeAll(async () => {
|
|
52
|
-
({ createJournalStore, loadConfig } = await import("./journal"));
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("loadConfig", () => {
|
|
56
|
-
test("classifies a missing file and returns an empty config", async () => {
|
|
57
|
-
const dir = await mkTmpDir();
|
|
58
|
-
const loaded = await loadConfig(dir);
|
|
59
|
-
expect(loaded.status).toBe("missing");
|
|
60
|
-
expect(loaded.config).toEqual({});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("classifies an unreadable file and returns an empty config", async () => {
|
|
64
|
-
const dir = await mkTmpDir();
|
|
65
|
-
await fs.mkdir(path.join(dir, "agent-memory.json"));
|
|
66
|
-
const loaded = await loadConfig(dir);
|
|
67
|
-
expect(loaded.status).toBe("unreadable");
|
|
68
|
-
expect(loaded.config).toEqual({});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("classifies malformed JSON and returns an empty config", async () => {
|
|
72
|
-
const dir = await mkTmpDir();
|
|
73
|
-
await fs.writeFile(path.join(dir, "agent-memory.json"), "not json{{{");
|
|
74
|
-
const loaded = await loadConfig(dir);
|
|
75
|
-
expect(loaded.status).toBe("malformed");
|
|
76
|
-
expect(loaded.config).toEqual({});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("classifies schema-invalid JSON and returns an empty config", async () => {
|
|
80
|
-
const dir = await mkTmpDir();
|
|
81
|
-
await fs.writeFile(
|
|
82
|
-
path.join(dir, "agent-memory.json"),
|
|
83
|
-
JSON.stringify({ journal: { enabled: "yes" } }),
|
|
84
|
-
);
|
|
85
|
-
const loaded = await loadConfig(dir);
|
|
86
|
-
expect(loaded.status).toBe("invalid");
|
|
87
|
-
expect(loaded.config).toEqual({});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("rejects an unrecognized journal key instead of silently enabling", async () => {
|
|
91
|
-
const dir = await mkTmpDir();
|
|
92
|
-
await fs.writeFile(path.join(dir, "agent-memory.json"), JSON.stringify({ journal: { enabled_: false } }));
|
|
93
|
-
const loaded = await loadConfig(dir);
|
|
94
|
-
expect(loaded.status).toBe("invalid");
|
|
95
|
-
expect(loaded.config).toEqual({});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("classifies valid disabled config without changing it", async () => {
|
|
99
|
-
const dir = await mkTmpDir();
|
|
100
|
-
await fs.writeFile(
|
|
101
|
-
path.join(dir, "agent-memory.json"),
|
|
102
|
-
JSON.stringify({ journal: { enabled: false } }),
|
|
103
|
-
);
|
|
104
|
-
const loaded = await loadConfig(dir);
|
|
105
|
-
expect(loaded.status).toBe("ok");
|
|
106
|
-
expect(loaded.config).toEqual({ journal: { enabled: false } });
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("classifies valid enabled config without changing it", async () => {
|
|
110
|
-
const dir = await mkTmpDir();
|
|
111
|
-
await fs.writeFile(
|
|
112
|
-
path.join(dir, "agent-memory.json"),
|
|
113
|
-
JSON.stringify({ journal: { enabled: true } }),
|
|
114
|
-
);
|
|
115
|
-
const loaded = await loadConfig(dir);
|
|
116
|
-
expect(loaded.status).toBe("ok");
|
|
117
|
-
expect(loaded.config).toEqual({ journal: { enabled: true } });
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("returns custom tags from config", async () => {
|
|
121
|
-
const dir = await mkTmpDir();
|
|
122
|
-
await fs.writeFile(
|
|
123
|
-
path.join(dir, "agent-memory.json"),
|
|
124
|
-
JSON.stringify({
|
|
125
|
-
journal: {
|
|
126
|
-
enabled: true,
|
|
127
|
-
tags: [
|
|
128
|
-
{ name: "perf", description: "Performance optimization work" },
|
|
129
|
-
{ name: "debug", description: "Debugging sessions" },
|
|
130
|
-
],
|
|
131
|
-
},
|
|
132
|
-
}),
|
|
133
|
-
);
|
|
134
|
-
const loaded = await loadConfig(dir);
|
|
135
|
-
expect(loaded.config.journal?.tags).toEqual([
|
|
136
|
-
{ name: "perf", description: "Performance optimization work" },
|
|
137
|
-
{ name: "debug", description: "Debugging sessions" },
|
|
138
|
-
]);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("journal store", () => {
|
|
143
|
-
let tmpDir: string;
|
|
144
|
-
|
|
145
|
-
afterEach(async () => {
|
|
146
|
-
if (tmpDir) {
|
|
147
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("write creates entry file with correct metadata", async () => {
|
|
152
|
-
tmpDir = await mkTmpDir();
|
|
153
|
-
const store = createJournalStore(tmpDir);
|
|
154
|
-
|
|
155
|
-
const entry = await store.write({
|
|
156
|
-
title: "Test insight",
|
|
157
|
-
body: "Discovered an interesting pattern.",
|
|
158
|
-
project: "/home/user/project",
|
|
159
|
-
model: "claude-opus-4-6",
|
|
160
|
-
provider: "anthropic",
|
|
161
|
-
tags: ["testing"],
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
expect(entry.title).toBe("Test insight");
|
|
165
|
-
expect(entry.project).toBe("/home/user/project");
|
|
166
|
-
expect(entry.model).toBe("claude-opus-4-6");
|
|
167
|
-
expect(entry.provider).toBe("anthropic");
|
|
168
|
-
expect(entry.tags).toEqual(["testing"]);
|
|
169
|
-
expect(entry.body).toBe("Discovered an interesting pattern.");
|
|
170
|
-
expect(entry.id).toMatch(/^\d{8}-\d{6}-\d{3}$/);
|
|
171
|
-
|
|
172
|
-
// Verify file exists
|
|
173
|
-
const raw = await fs.readFile(entry.filePath, "utf-8");
|
|
174
|
-
expect(raw).toContain("title: Test insight");
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("write generates chronological filenames", async () => {
|
|
178
|
-
tmpDir = await mkTmpDir();
|
|
179
|
-
const store = createJournalStore(tmpDir);
|
|
180
|
-
|
|
181
|
-
const e1 = await store.write({ title: "First", body: "First entry" });
|
|
182
|
-
// Small delay to ensure different timestamp
|
|
183
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
184
|
-
const e2 = await store.write({ title: "Second", body: "Second entry" });
|
|
185
|
-
|
|
186
|
-
expect(e2.id > e1.id).toBe(true);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test("write saves embedding file alongside entry", async () => {
|
|
190
|
-
tmpDir = await mkTmpDir();
|
|
191
|
-
const store = createJournalStore(tmpDir);
|
|
192
|
-
|
|
193
|
-
const entry = await store.write({
|
|
194
|
-
title: "Embedding test",
|
|
195
|
-
body: "This should get an embedding.",
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
199
|
-
const raw = await fs.readFile(embeddingFile, "utf-8");
|
|
200
|
-
const embedding = JSON.parse(raw);
|
|
201
|
-
// v0.4.0: versioned embedding object, not a bare array
|
|
202
|
-
expect(embedding.v).toBe(2);
|
|
203
|
-
expect(embedding.model).toBe("test-model");
|
|
204
|
-
expect(embedding.dimension).toBe(8);
|
|
205
|
-
expect(Array.isArray(embedding.vector)).toBe(true);
|
|
206
|
-
expect(embedding.vector.length).toBeGreaterThan(0);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test("read returns full entry by id", async () => {
|
|
210
|
-
tmpDir = await mkTmpDir();
|
|
211
|
-
const store = createJournalStore(tmpDir);
|
|
212
|
-
|
|
213
|
-
const written = await store.write({
|
|
214
|
-
title: "Read test",
|
|
215
|
-
body: "Read me back.",
|
|
216
|
-
tags: ["test", "read"],
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
const read = await store.read(written.id);
|
|
220
|
-
expect(read.title).toBe("Read test");
|
|
221
|
-
expect(read.body).toBe("Read me back.");
|
|
222
|
-
expect(read.tags).toEqual(["test", "read"]);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
test("read throws for nonexistent id", async () => {
|
|
226
|
-
tmpDir = await mkTmpDir();
|
|
227
|
-
const store = createJournalStore(tmpDir);
|
|
228
|
-
|
|
229
|
-
expect(store.read("99990101-000000-000")).rejects.toThrow(
|
|
230
|
-
"Journal entry not found",
|
|
231
|
-
);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test("read rejects path traversal", async () => {
|
|
235
|
-
tmpDir = await mkTmpDir();
|
|
236
|
-
const store = createJournalStore(tmpDir);
|
|
237
|
-
|
|
238
|
-
expect(store.read("../../../etc/passwd")).rejects.toThrow(
|
|
239
|
-
"Invalid journal entry ID",
|
|
240
|
-
);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
test("search with no filters returns recent entries", async () => {
|
|
244
|
-
tmpDir = await mkTmpDir();
|
|
245
|
-
const store = createJournalStore(tmpDir);
|
|
246
|
-
|
|
247
|
-
await store.write({ title: "Entry 1", body: "First" });
|
|
248
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
249
|
-
await store.write({ title: "Entry 2", body: "Second" });
|
|
250
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
251
|
-
await store.write({ title: "Entry 3", body: "Third" });
|
|
252
|
-
|
|
253
|
-
const result = await store.search({});
|
|
254
|
-
expect(result.total).toBe(3);
|
|
255
|
-
expect(result.entries.length).toBe(3);
|
|
256
|
-
// Newest first (by recency score)
|
|
257
|
-
expect(result.entries[0]!.title).toBe("Entry 3");
|
|
258
|
-
expect(result.entries[2]!.title).toBe("Entry 1");
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
test("search filters by tags", async () => {
|
|
262
|
-
tmpDir = await mkTmpDir();
|
|
263
|
-
const store = createJournalStore(tmpDir);
|
|
264
|
-
|
|
265
|
-
await store.write({
|
|
266
|
-
title: "Tagged",
|
|
267
|
-
body: "...",
|
|
268
|
-
tags: ["rust", "perf"],
|
|
269
|
-
});
|
|
270
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
271
|
-
await store.write({
|
|
272
|
-
title: "Other",
|
|
273
|
-
body: "...",
|
|
274
|
-
tags: ["python"],
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
const result = await store.search({ tags: ["rust"] });
|
|
278
|
-
expect(result.total).toBe(1);
|
|
279
|
-
expect(result.entries[0]!.title).toBe("Tagged");
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("search filters by project", async () => {
|
|
283
|
-
tmpDir = await mkTmpDir();
|
|
284
|
-
const store = createJournalStore(tmpDir);
|
|
285
|
-
|
|
286
|
-
await store.write({ title: "Project A", body: "...", project: "/proj/a" });
|
|
287
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
288
|
-
await store.write({ title: "Project B", body: "...", project: "/proj/b" });
|
|
289
|
-
|
|
290
|
-
const result = await store.search({ project: "/proj/a" });
|
|
291
|
-
expect(result.total).toBe(1);
|
|
292
|
-
expect(result.entries[0]!.title).toBe("Project A");
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
test("search combines filters with AND logic", async () => {
|
|
296
|
-
tmpDir = await mkTmpDir();
|
|
297
|
-
const store = createJournalStore(tmpDir);
|
|
298
|
-
|
|
299
|
-
await store.write({
|
|
300
|
-
title: "Match",
|
|
301
|
-
body: "...",
|
|
302
|
-
tags: ["rust"],
|
|
303
|
-
project: "/proj/a",
|
|
304
|
-
});
|
|
305
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
306
|
-
await store.write({
|
|
307
|
-
title: "Wrong tags",
|
|
308
|
-
body: "...",
|
|
309
|
-
tags: ["python"],
|
|
310
|
-
project: "/proj/a",
|
|
311
|
-
});
|
|
312
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
313
|
-
await store.write({
|
|
314
|
-
title: "Wrong project",
|
|
315
|
-
body: "...",
|
|
316
|
-
tags: ["rust"],
|
|
317
|
-
project: "/proj/b",
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
const result = await store.search({
|
|
321
|
-
tags: ["rust"],
|
|
322
|
-
project: "/proj/a",
|
|
323
|
-
});
|
|
324
|
-
expect(result.total).toBe(1);
|
|
325
|
-
expect(result.entries[0]!.title).toBe("Match");
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
test("search respects limit", async () => {
|
|
329
|
-
tmpDir = await mkTmpDir();
|
|
330
|
-
const store = createJournalStore(tmpDir);
|
|
331
|
-
|
|
332
|
-
for (let i = 0; i < 5; i++) {
|
|
333
|
-
await store.write({ title: `Entry ${i}`, body: `Body ${i}` });
|
|
334
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const result = await store.search({ limit: 2 });
|
|
338
|
-
expect(result.entries.length).toBe(2);
|
|
339
|
-
expect(result.total).toBe(5);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
test("search supports offset pagination", async () => {
|
|
343
|
-
tmpDir = await mkTmpDir();
|
|
344
|
-
const store = createJournalStore(tmpDir);
|
|
345
|
-
|
|
346
|
-
for (let i = 0; i < 5; i++) {
|
|
347
|
-
await store.write({ title: `Entry ${i}`, body: `Body ${i}` });
|
|
348
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const page1 = await store.search({ limit: 2, offset: 0 });
|
|
352
|
-
expect(page1.entries.length).toBe(2);
|
|
353
|
-
expect(page1.total).toBe(5);
|
|
354
|
-
// Newest first
|
|
355
|
-
expect(page1.entries[0]!.title).toBe("Entry 4");
|
|
356
|
-
expect(page1.entries[1]!.title).toBe("Entry 3");
|
|
357
|
-
|
|
358
|
-
const page2 = await store.search({ limit: 2, offset: 2 });
|
|
359
|
-
expect(page2.entries.length).toBe(2);
|
|
360
|
-
expect(page2.total).toBe(5);
|
|
361
|
-
expect(page2.entries[0]!.title).toBe("Entry 2");
|
|
362
|
-
expect(page2.entries[1]!.title).toBe("Entry 1");
|
|
363
|
-
|
|
364
|
-
const page3 = await store.search({ limit: 2, offset: 4 });
|
|
365
|
-
expect(page3.entries.length).toBe(1);
|
|
366
|
-
expect(page3.entries[0]!.title).toBe("Entry 0");
|
|
367
|
-
|
|
368
|
-
// Past the end
|
|
369
|
-
const page4 = await store.search({ limit: 2, offset: 10 });
|
|
370
|
-
expect(page4.entries.length).toBe(0);
|
|
371
|
-
expect(page4.total).toBe(5);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
test("search handles empty journal directory", async () => {
|
|
375
|
-
tmpDir = await mkTmpDir();
|
|
376
|
-
const store = createJournalStore(tmpDir);
|
|
377
|
-
|
|
378
|
-
const result = await store.search({});
|
|
379
|
-
expect(result.entries.length).toBe(0);
|
|
380
|
-
expect(result.total).toBe(0);
|
|
381
|
-
expect(result.allTags).toEqual([]);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("search returns allTags from all entries regardless of filters", async () => {
|
|
385
|
-
tmpDir = await mkTmpDir();
|
|
386
|
-
const store = createJournalStore(tmpDir);
|
|
387
|
-
|
|
388
|
-
await store.write({
|
|
389
|
-
title: "Rust work",
|
|
390
|
-
body: "...",
|
|
391
|
-
tags: ["rust", "perf"],
|
|
392
|
-
});
|
|
393
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
394
|
-
await store.write({
|
|
395
|
-
title: "Python work",
|
|
396
|
-
body: "...",
|
|
397
|
-
tags: ["python", "testing"],
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
// Filter to only rust tag — allTags should still include all tags
|
|
401
|
-
const result = await store.search({ tags: ["rust"] });
|
|
402
|
-
expect(result.total).toBe(1);
|
|
403
|
-
expect(result.allTags).toEqual(["perf", "python", "rust", "testing"]);
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
test("search allTags deduplicates across entries", async () => {
|
|
407
|
-
tmpDir = await mkTmpDir();
|
|
408
|
-
const store = createJournalStore(tmpDir);
|
|
409
|
-
|
|
410
|
-
await store.write({ title: "A", body: "...", tags: ["rust", "perf"] });
|
|
411
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
412
|
-
await store.write({ title: "B", body: "...", tags: ["rust", "debugging"] });
|
|
413
|
-
|
|
414
|
-
const result = await store.search({});
|
|
415
|
-
expect(result.allTags).toEqual(["debugging", "perf", "rust"]);
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
test("search by text uses semantic matching", async () => {
|
|
419
|
-
tmpDir = await mkTmpDir();
|
|
420
|
-
const store = createJournalStore(tmpDir);
|
|
421
|
-
|
|
422
|
-
await store.write({
|
|
423
|
-
title: "Rust performance",
|
|
424
|
-
body: "Optimized the hot loop using SIMD instructions.",
|
|
425
|
-
});
|
|
426
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
427
|
-
await store.write({
|
|
428
|
-
title: "Python testing",
|
|
429
|
-
body: "Set up pytest with coverage reporting.",
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
// With mock embeddings, text search falls through to semantic matching
|
|
433
|
-
const result = await store.search({ text: "Rust performance" });
|
|
434
|
-
// Should find at least the matching entry
|
|
435
|
-
expect(result.total).toBeGreaterThan(0);
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
test("title query surfaces long-body entry despite diluted embedding", async () => {
|
|
439
|
-
tmpDir = await mkTmpDir();
|
|
440
|
-
const store = createJournalStore(tmpDir);
|
|
441
|
-
const entry = await store.write({
|
|
442
|
-
title: "Agent-memory release delivery channel",
|
|
443
|
-
body: "Unrelated topics include coastal erosion, orbital mechanics, ceramic glazing, " +
|
|
444
|
-
"database indexing, ancient trade routes, orchard irrigation, keyboard switches, " +
|
|
445
|
-
"bird migration, public transit planning, and archival paper chemistry. These notes " +
|
|
446
|
-
"continue across varied subjects so the long body dilutes its mean-pooled embedding. " +
|
|
447
|
-
"Further unrelated details cover weather instruments, typography, fermentation, " +
|
|
448
|
-
"geology, woodworking, astronomy, logistics, and museum conservation. The entry is " +
|
|
449
|
-
"deliberately verbose and semantically unrelated to a fresh exact-title query, while " +
|
|
450
|
-
"its title remains the searchable anchor for this regression case.",
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
// The deterministic mock embeddings produce an unrelated query vector here;
|
|
454
|
-
// the title-anchor floor guarantees presence regardless of cosine score.
|
|
455
|
-
const result = await store.search({ text: "Agent-memory release delivery channel" });
|
|
456
|
-
expect(result.entries.some((candidate) => candidate.id === entry.id)).toBe(true);
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
test("search skips stale-dimension embedding and falls back to text match", async () => {
|
|
460
|
-
tmpDir = await mkTmpDir();
|
|
461
|
-
const store = createJournalStore(tmpDir);
|
|
462
|
-
|
|
463
|
-
const entry = await store.write({
|
|
464
|
-
title: "Stale dims entry",
|
|
465
|
-
body: "unique-stale-dim-content",
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
// First search: caches the entry with the correct mock embedding.
|
|
469
|
-
await store.search({ text: "unique-stale-dim-content" });
|
|
470
|
-
|
|
471
|
-
// Simulate a v2 embedding with a WRONG dimension (e.g. from a 768d model).
|
|
472
|
-
// Search must not throw: it should skip the embedding and fall back to
|
|
473
|
-
// text matching for this entry.
|
|
474
|
-
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
475
|
-
await fs.writeFile(
|
|
476
|
-
embeddingFile,
|
|
477
|
-
JSON.stringify({
|
|
478
|
-
v: 2,
|
|
479
|
-
model: "some-768d-model",
|
|
480
|
-
dimension: 768,
|
|
481
|
-
vector: Array.from({ length: 768 }, () => 0.1),
|
|
482
|
-
}),
|
|
483
|
-
"utf-8",
|
|
484
|
-
);
|
|
485
|
-
|
|
486
|
-
// Query whose embedding would collide with the stale one if cosine were
|
|
487
|
-
// attempted (dimension mismatch would throw inside cosineSimilarity).
|
|
488
|
-
const result = await store.search({ text: "unique-stale-dim-content" });
|
|
489
|
-
const titles = result.entries.map((e) => e.title);
|
|
490
|
-
expect(titles).toContain("Stale dims entry");
|
|
491
|
-
// No throw above is the assertion: mismatched dimension degrades to text.
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
test("sidecar rewrite after cache warm is picked up by second search", async () => {
|
|
495
|
-
tmpDir = await mkTmpDir();
|
|
496
|
-
const store = createJournalStore(tmpDir);
|
|
497
|
-
|
|
498
|
-
const entry = await store.write({
|
|
499
|
-
title: "Sidecar invalidation",
|
|
500
|
-
body: "unique-sidecar-invalidation-content",
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
// First search: caches the entry + correct sidecar fingerprint.
|
|
504
|
-
const r1 = await store.search({ text: "unique-sidecar-invalidation-content" });
|
|
505
|
-
expect(r1.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
|
|
506
|
-
|
|
507
|
-
// Rewrite the sidecar with a wrong-dimension embedding (simulating
|
|
508
|
-
// sidecar regeneration without touching the .md).
|
|
509
|
-
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
510
|
-
await fs.writeFile(
|
|
511
|
-
embeddingFile,
|
|
512
|
-
JSON.stringify({
|
|
513
|
-
v: 2,
|
|
514
|
-
model: "some-768d-model",
|
|
515
|
-
dimension: 768,
|
|
516
|
-
vector: Array.from({ length: 768 }, () => 0.1),
|
|
517
|
-
}),
|
|
518
|
-
"utf-8",
|
|
519
|
-
);
|
|
520
|
-
|
|
521
|
-
// Second search must invalidate the cache (sidecar mtime changed) and
|
|
522
|
-
// pick up the stale-dimension embedding, falling back to text match.
|
|
523
|
-
const r2 = await store.search({ text: "unique-sidecar-invalidation-content" });
|
|
524
|
-
expect(r2.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
test("sidecar deleted between searches falls back to text match", async () => {
|
|
528
|
-
tmpDir = await mkTmpDir();
|
|
529
|
-
const store = createJournalStore(tmpDir);
|
|
530
|
-
|
|
531
|
-
const entry = await store.write({
|
|
532
|
-
title: "Sidecar deleted entry",
|
|
533
|
-
body: "unique-sidecar-deleted-content",
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
// Warm the cache chronologically (loads and caches the sidecar
|
|
537
|
-
// fingerprint) without depending on a synthetic semantic score.
|
|
538
|
-
const r1 = await store.search({});
|
|
539
|
-
expect(r1.entries.some((e) => e.title === "Sidecar deleted entry")).toBe(true);
|
|
540
|
-
|
|
541
|
-
// Delete the sidecar file.
|
|
542
|
-
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
543
|
-
await fs.unlink(embeddingFile);
|
|
544
|
-
|
|
545
|
-
// Second search: cache miss (sidecar gone), falls back to text match
|
|
546
|
-
// (score 0.5) rather than using the stale cached embedding.
|
|
547
|
-
const r2 = await store.search({ text: "unique-sidecar-deleted-content" });
|
|
548
|
-
const found = r2.entries.find((e) => e.title === "Sidecar deleted entry");
|
|
549
|
-
expect(found).toBeDefined();
|
|
550
|
-
// Score is 0.5 (text fallback), not a cosine score
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
test("search skips corrupt v2 embedding (empty vector) without throwing", async () => {
|
|
554
|
-
tmpDir = await mkTmpDir();
|
|
555
|
-
const store = createJournalStore(tmpDir);
|
|
556
|
-
|
|
557
|
-
const entry = await store.write({
|
|
558
|
-
title: "Corrupt embedding entry",
|
|
559
|
-
body: "unique-corrupt-embedding-content",
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
// Simulate a corrupt-but-well-formed v2 embedding: dimension claims 384
|
|
563
|
-
// but the vector is empty. Search must NOT throw — it should skip the
|
|
564
|
-
// embedding and fall back to text matching for this entry.
|
|
565
|
-
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
566
|
-
await fs.writeFile(
|
|
567
|
-
embeddingFile,
|
|
568
|
-
JSON.stringify({ v: 2, model: "some-model", dimension: 8, vector: [] }),
|
|
569
|
-
"utf-8",
|
|
570
|
-
);
|
|
571
|
-
|
|
572
|
-
const result = await store.search({ text: "unique-corrupt-embedding-content" });
|
|
573
|
-
const titles = result.entries.map((e) => e.title);
|
|
574
|
-
expect(titles).toContain("Corrupt embedding entry");
|
|
575
|
-
// No throw above is the assertion: corrupt v2 embedding degrades to text.
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
test("search skips corrupt v2 embedding (missing vector) without throwing", async () => {
|
|
579
|
-
tmpDir = await mkTmpDir();
|
|
580
|
-
const store = createJournalStore(tmpDir);
|
|
581
|
-
|
|
582
|
-
const entry = await store.write({
|
|
583
|
-
title: "Missing vector entry",
|
|
584
|
-
body: "unique-missing-vector-content",
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
// v2 file that omits the vector field entirely. Search must not throw.
|
|
588
|
-
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
589
|
-
await fs.writeFile(
|
|
590
|
-
embeddingFile,
|
|
591
|
-
JSON.stringify({ v: 2, model: "some-model", dimension: 8 }),
|
|
592
|
-
"utf-8",
|
|
593
|
-
);
|
|
594
|
-
|
|
595
|
-
const result = await store.search({ text: "unique-missing-vector-content" });
|
|
596
|
-
const titles = result.entries.map((e) => e.title);
|
|
597
|
-
expect(titles).toContain("Missing vector entry");
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
test("legacy bare-array embedding is still usable for semantic search", async () => {
|
|
601
|
-
tmpDir = await mkTmpDir();
|
|
602
|
-
const store = createJournalStore(tmpDir);
|
|
603
|
-
|
|
604
|
-
const entry = await store.write({
|
|
605
|
-
title: "Legacy embedding",
|
|
606
|
-
body: "legacy-vector-body",
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
// Overwrite with a legacy v1 bare-array embedding (from all-MiniLM-L6-v2,
|
|
610
|
-
// which also produces 8-dim vectors under the test mock). Use the same
|
|
611
|
-
// mock formula as generateEmbedding so the cosine is positive and the
|
|
612
|
-
// entry is actually matched via semantic search.
|
|
613
|
-
const hash = Array.from("legacy-vector-body").reduce(
|
|
614
|
-
(acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
|
|
615
|
-
0,
|
|
616
|
-
);
|
|
617
|
-
const legacyVector = Array.from(
|
|
618
|
-
{ length: 8 },
|
|
619
|
-
(_, i) => Math.sin(hash + i),
|
|
620
|
-
);
|
|
621
|
-
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
622
|
-
await fs.writeFile(
|
|
623
|
-
embeddingFile,
|
|
624
|
-
JSON.stringify(legacyVector),
|
|
625
|
-
"utf-8",
|
|
626
|
-
);
|
|
627
|
-
|
|
628
|
-
// Legacy array must be treated as { vector, model: "unknown-legacy",
|
|
629
|
-
// dimension: length }; with a matching dimension it stays usable.
|
|
630
|
-
const result = await store.search({ text: "legacy-vector-body" });
|
|
631
|
-
const titles = result.entries.map((e) => e.title);
|
|
632
|
-
expect(titles).toContain("Legacy embedding");
|
|
633
|
-
});
|
|
634
|
-
});
|