@ghilteras/opencode-agent-memory 0.3.0
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/LICENSE +9 -0
- package/README.md +180 -0
- package/package.json +42 -0
- package/src/embeddings.ts +46 -0
- package/src/frontmatter.ts +47 -0
- package/src/journal.test.ts +384 -0
- package/src/journal.ts +358 -0
- package/src/letta.ts +47 -0
- package/src/memory.test.ts +29 -0
- package/src/memory.ts +300 -0
- package/src/plugin.ts +80 -0
- package/src/prompt.test.ts +158 -0
- package/src/prompt.ts +58 -0
- package/src/tools.ts +200 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { createJournalStore, loadConfig } from "./journal";
|
|
6
|
+
|
|
7
|
+
// Mock the embeddings module to avoid downloading a real model in tests
|
|
8
|
+
mock.module("./embeddings", () => ({
|
|
9
|
+
generateEmbedding: async (text: string) => {
|
|
10
|
+
// Deterministic fake embedding based on text content
|
|
11
|
+
const hash = Array.from(text).reduce(
|
|
12
|
+
(acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
|
|
13
|
+
0,
|
|
14
|
+
);
|
|
15
|
+
return Array.from({ length: 8 }, (_, i) => Math.sin(hash + i));
|
|
16
|
+
},
|
|
17
|
+
cosineSimilarity: (a: number[], b: number[]) => {
|
|
18
|
+
let dot = 0;
|
|
19
|
+
let normA = 0;
|
|
20
|
+
let normB = 0;
|
|
21
|
+
for (let i = 0; i < a.length; i++) {
|
|
22
|
+
const ai = a[i]!;
|
|
23
|
+
const bi = b[i]!;
|
|
24
|
+
dot += ai * bi;
|
|
25
|
+
normA += ai * ai;
|
|
26
|
+
normB += bi * bi;
|
|
27
|
+
}
|
|
28
|
+
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
|
29
|
+
return denom === 0 ? 0 : dot / denom;
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
async function mkTmpDir(): Promise<string> {
|
|
34
|
+
return fs.mkdtemp(path.join("/tmp/", "opencode-journal-"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("loadConfig", () => {
|
|
38
|
+
test("returns empty config when file does not exist", async () => {
|
|
39
|
+
const dir = await mkTmpDir();
|
|
40
|
+
const config = await loadConfig(dir);
|
|
41
|
+
expect(config).toEqual({});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns parsed config when file is valid", async () => {
|
|
45
|
+
const dir = await mkTmpDir();
|
|
46
|
+
await fs.writeFile(
|
|
47
|
+
path.join(dir, "agent-memory.json"),
|
|
48
|
+
JSON.stringify({ journal: { enabled: true } }),
|
|
49
|
+
);
|
|
50
|
+
const config = await loadConfig(dir);
|
|
51
|
+
expect(config.journal?.enabled).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("returns empty config when file has invalid JSON", async () => {
|
|
55
|
+
const dir = await mkTmpDir();
|
|
56
|
+
await fs.writeFile(path.join(dir, "agent-memory.json"), "not json{{{");
|
|
57
|
+
const config = await loadConfig(dir);
|
|
58
|
+
expect(config).toEqual({});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns custom tags from config", async () => {
|
|
62
|
+
const dir = await mkTmpDir();
|
|
63
|
+
await fs.writeFile(
|
|
64
|
+
path.join(dir, "agent-memory.json"),
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
journal: {
|
|
67
|
+
enabled: true,
|
|
68
|
+
tags: [
|
|
69
|
+
{ name: "perf", description: "Performance optimization work" },
|
|
70
|
+
{ name: "debug", description: "Debugging sessions" },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
const config = await loadConfig(dir);
|
|
76
|
+
expect(config.journal?.tags).toEqual([
|
|
77
|
+
{ name: "perf", description: "Performance optimization work" },
|
|
78
|
+
{ name: "debug", description: "Debugging sessions" },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("returns empty config when schema validation fails", async () => {
|
|
83
|
+
const dir = await mkTmpDir();
|
|
84
|
+
await fs.writeFile(
|
|
85
|
+
path.join(dir, "agent-memory.json"),
|
|
86
|
+
JSON.stringify({ journal: { enabled: "yes" } }),
|
|
87
|
+
);
|
|
88
|
+
const config = await loadConfig(dir);
|
|
89
|
+
expect(config).toEqual({});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("journal store", () => {
|
|
94
|
+
let tmpDir: string;
|
|
95
|
+
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
if (tmpDir) {
|
|
98
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("write creates entry file with correct metadata", async () => {
|
|
103
|
+
tmpDir = await mkTmpDir();
|
|
104
|
+
const store = createJournalStore(tmpDir);
|
|
105
|
+
|
|
106
|
+
const entry = await store.write({
|
|
107
|
+
title: "Test insight",
|
|
108
|
+
body: "Discovered an interesting pattern.",
|
|
109
|
+
project: "/home/user/project",
|
|
110
|
+
model: "claude-opus-4-6",
|
|
111
|
+
provider: "anthropic",
|
|
112
|
+
tags: ["testing"],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(entry.title).toBe("Test insight");
|
|
116
|
+
expect(entry.project).toBe("/home/user/project");
|
|
117
|
+
expect(entry.model).toBe("claude-opus-4-6");
|
|
118
|
+
expect(entry.provider).toBe("anthropic");
|
|
119
|
+
expect(entry.tags).toEqual(["testing"]);
|
|
120
|
+
expect(entry.body).toBe("Discovered an interesting pattern.");
|
|
121
|
+
expect(entry.id).toMatch(/^\d{8}-\d{6}-\d{3}$/);
|
|
122
|
+
|
|
123
|
+
// Verify file exists
|
|
124
|
+
const raw = await fs.readFile(entry.filePath, "utf-8");
|
|
125
|
+
expect(raw).toContain("title: Test insight");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("write generates chronological filenames", async () => {
|
|
129
|
+
tmpDir = await mkTmpDir();
|
|
130
|
+
const store = createJournalStore(tmpDir);
|
|
131
|
+
|
|
132
|
+
const e1 = await store.write({ title: "First", body: "First entry" });
|
|
133
|
+
// Small delay to ensure different timestamp
|
|
134
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
135
|
+
const e2 = await store.write({ title: "Second", body: "Second entry" });
|
|
136
|
+
|
|
137
|
+
expect(e2.id > e1.id).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("write saves embedding file alongside entry", async () => {
|
|
141
|
+
tmpDir = await mkTmpDir();
|
|
142
|
+
const store = createJournalStore(tmpDir);
|
|
143
|
+
|
|
144
|
+
const entry = await store.write({
|
|
145
|
+
title: "Embedding test",
|
|
146
|
+
body: "This should get an embedding.",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
150
|
+
const raw = await fs.readFile(embeddingFile, "utf-8");
|
|
151
|
+
const embedding = JSON.parse(raw);
|
|
152
|
+
expect(Array.isArray(embedding)).toBe(true);
|
|
153
|
+
expect(embedding.length).toBeGreaterThan(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("read returns full entry by id", async () => {
|
|
157
|
+
tmpDir = await mkTmpDir();
|
|
158
|
+
const store = createJournalStore(tmpDir);
|
|
159
|
+
|
|
160
|
+
const written = await store.write({
|
|
161
|
+
title: "Read test",
|
|
162
|
+
body: "Read me back.",
|
|
163
|
+
tags: ["test", "read"],
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const read = await store.read(written.id);
|
|
167
|
+
expect(read.title).toBe("Read test");
|
|
168
|
+
expect(read.body).toBe("Read me back.");
|
|
169
|
+
expect(read.tags).toEqual(["test", "read"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("read throws for nonexistent id", async () => {
|
|
173
|
+
tmpDir = await mkTmpDir();
|
|
174
|
+
const store = createJournalStore(tmpDir);
|
|
175
|
+
|
|
176
|
+
expect(store.read("99990101-000000-000")).rejects.toThrow(
|
|
177
|
+
"Journal entry not found",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("read rejects path traversal", async () => {
|
|
182
|
+
tmpDir = await mkTmpDir();
|
|
183
|
+
const store = createJournalStore(tmpDir);
|
|
184
|
+
|
|
185
|
+
expect(store.read("../../../etc/passwd")).rejects.toThrow(
|
|
186
|
+
"Invalid journal entry ID",
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("search with no filters returns recent entries", async () => {
|
|
191
|
+
tmpDir = await mkTmpDir();
|
|
192
|
+
const store = createJournalStore(tmpDir);
|
|
193
|
+
|
|
194
|
+
await store.write({ title: "Entry 1", body: "First" });
|
|
195
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
196
|
+
await store.write({ title: "Entry 2", body: "Second" });
|
|
197
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
198
|
+
await store.write({ title: "Entry 3", body: "Third" });
|
|
199
|
+
|
|
200
|
+
const result = await store.search({});
|
|
201
|
+
expect(result.total).toBe(3);
|
|
202
|
+
expect(result.entries.length).toBe(3);
|
|
203
|
+
// Newest first (by recency score)
|
|
204
|
+
expect(result.entries[0]!.title).toBe("Entry 3");
|
|
205
|
+
expect(result.entries[2]!.title).toBe("Entry 1");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("search filters by tags", async () => {
|
|
209
|
+
tmpDir = await mkTmpDir();
|
|
210
|
+
const store = createJournalStore(tmpDir);
|
|
211
|
+
|
|
212
|
+
await store.write({
|
|
213
|
+
title: "Tagged",
|
|
214
|
+
body: "...",
|
|
215
|
+
tags: ["rust", "perf"],
|
|
216
|
+
});
|
|
217
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
218
|
+
await store.write({
|
|
219
|
+
title: "Other",
|
|
220
|
+
body: "...",
|
|
221
|
+
tags: ["python"],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await store.search({ tags: ["rust"] });
|
|
225
|
+
expect(result.total).toBe(1);
|
|
226
|
+
expect(result.entries[0]!.title).toBe("Tagged");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("search filters by project", async () => {
|
|
230
|
+
tmpDir = await mkTmpDir();
|
|
231
|
+
const store = createJournalStore(tmpDir);
|
|
232
|
+
|
|
233
|
+
await store.write({ title: "Project A", body: "...", project: "/proj/a" });
|
|
234
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
235
|
+
await store.write({ title: "Project B", body: "...", project: "/proj/b" });
|
|
236
|
+
|
|
237
|
+
const result = await store.search({ project: "/proj/a" });
|
|
238
|
+
expect(result.total).toBe(1);
|
|
239
|
+
expect(result.entries[0]!.title).toBe("Project A");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("search combines filters with AND logic", async () => {
|
|
243
|
+
tmpDir = await mkTmpDir();
|
|
244
|
+
const store = createJournalStore(tmpDir);
|
|
245
|
+
|
|
246
|
+
await store.write({
|
|
247
|
+
title: "Match",
|
|
248
|
+
body: "...",
|
|
249
|
+
tags: ["rust"],
|
|
250
|
+
project: "/proj/a",
|
|
251
|
+
});
|
|
252
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
253
|
+
await store.write({
|
|
254
|
+
title: "Wrong tags",
|
|
255
|
+
body: "...",
|
|
256
|
+
tags: ["python"],
|
|
257
|
+
project: "/proj/a",
|
|
258
|
+
});
|
|
259
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
260
|
+
await store.write({
|
|
261
|
+
title: "Wrong project",
|
|
262
|
+
body: "...",
|
|
263
|
+
tags: ["rust"],
|
|
264
|
+
project: "/proj/b",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const result = await store.search({
|
|
268
|
+
tags: ["rust"],
|
|
269
|
+
project: "/proj/a",
|
|
270
|
+
});
|
|
271
|
+
expect(result.total).toBe(1);
|
|
272
|
+
expect(result.entries[0]!.title).toBe("Match");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("search respects limit", async () => {
|
|
276
|
+
tmpDir = await mkTmpDir();
|
|
277
|
+
const store = createJournalStore(tmpDir);
|
|
278
|
+
|
|
279
|
+
for (let i = 0; i < 5; i++) {
|
|
280
|
+
await store.write({ title: `Entry ${i}`, body: `Body ${i}` });
|
|
281
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const result = await store.search({ limit: 2 });
|
|
285
|
+
expect(result.entries.length).toBe(2);
|
|
286
|
+
expect(result.total).toBe(5);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("search supports offset pagination", async () => {
|
|
290
|
+
tmpDir = await mkTmpDir();
|
|
291
|
+
const store = createJournalStore(tmpDir);
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < 5; i++) {
|
|
294
|
+
await store.write({ title: `Entry ${i}`, body: `Body ${i}` });
|
|
295
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const page1 = await store.search({ limit: 2, offset: 0 });
|
|
299
|
+
expect(page1.entries.length).toBe(2);
|
|
300
|
+
expect(page1.total).toBe(5);
|
|
301
|
+
// Newest first
|
|
302
|
+
expect(page1.entries[0]!.title).toBe("Entry 4");
|
|
303
|
+
expect(page1.entries[1]!.title).toBe("Entry 3");
|
|
304
|
+
|
|
305
|
+
const page2 = await store.search({ limit: 2, offset: 2 });
|
|
306
|
+
expect(page2.entries.length).toBe(2);
|
|
307
|
+
expect(page2.total).toBe(5);
|
|
308
|
+
expect(page2.entries[0]!.title).toBe("Entry 2");
|
|
309
|
+
expect(page2.entries[1]!.title).toBe("Entry 1");
|
|
310
|
+
|
|
311
|
+
const page3 = await store.search({ limit: 2, offset: 4 });
|
|
312
|
+
expect(page3.entries.length).toBe(1);
|
|
313
|
+
expect(page3.entries[0]!.title).toBe("Entry 0");
|
|
314
|
+
|
|
315
|
+
// Past the end
|
|
316
|
+
const page4 = await store.search({ limit: 2, offset: 10 });
|
|
317
|
+
expect(page4.entries.length).toBe(0);
|
|
318
|
+
expect(page4.total).toBe(5);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("search handles empty journal directory", async () => {
|
|
322
|
+
tmpDir = await mkTmpDir();
|
|
323
|
+
const store = createJournalStore(tmpDir);
|
|
324
|
+
|
|
325
|
+
const result = await store.search({});
|
|
326
|
+
expect(result.entries.length).toBe(0);
|
|
327
|
+
expect(result.total).toBe(0);
|
|
328
|
+
expect(result.allTags).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("search returns allTags from all entries regardless of filters", async () => {
|
|
332
|
+
tmpDir = await mkTmpDir();
|
|
333
|
+
const store = createJournalStore(tmpDir);
|
|
334
|
+
|
|
335
|
+
await store.write({
|
|
336
|
+
title: "Rust work",
|
|
337
|
+
body: "...",
|
|
338
|
+
tags: ["rust", "perf"],
|
|
339
|
+
});
|
|
340
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
341
|
+
await store.write({
|
|
342
|
+
title: "Python work",
|
|
343
|
+
body: "...",
|
|
344
|
+
tags: ["python", "testing"],
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Filter to only rust tag — allTags should still include all tags
|
|
348
|
+
const result = await store.search({ tags: ["rust"] });
|
|
349
|
+
expect(result.total).toBe(1);
|
|
350
|
+
expect(result.allTags).toEqual(["perf", "python", "rust", "testing"]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("search allTags deduplicates across entries", async () => {
|
|
354
|
+
tmpDir = await mkTmpDir();
|
|
355
|
+
const store = createJournalStore(tmpDir);
|
|
356
|
+
|
|
357
|
+
await store.write({ title: "A", body: "...", tags: ["rust", "perf"] });
|
|
358
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
359
|
+
await store.write({ title: "B", body: "...", tags: ["rust", "debugging"] });
|
|
360
|
+
|
|
361
|
+
const result = await store.search({});
|
|
362
|
+
expect(result.allTags).toEqual(["debugging", "perf", "rust"]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("search by text uses semantic matching", async () => {
|
|
366
|
+
tmpDir = await mkTmpDir();
|
|
367
|
+
const store = createJournalStore(tmpDir);
|
|
368
|
+
|
|
369
|
+
await store.write({
|
|
370
|
+
title: "Rust performance",
|
|
371
|
+
body: "Optimized the hot loop using SIMD instructions.",
|
|
372
|
+
});
|
|
373
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
374
|
+
await store.write({
|
|
375
|
+
title: "Python testing",
|
|
376
|
+
body: "Set up pytest with coverage reporting.",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// With mock embeddings, text search falls through to semantic matching
|
|
380
|
+
const result = await store.search({ text: "Rust performance" });
|
|
381
|
+
// Should find at least the matching entry
|
|
382
|
+
expect(result.total).toBeGreaterThan(0);
|
|
383
|
+
});
|
|
384
|
+
});
|