@ghilteras/opencode-agent-memory 0.4.0 → 0.4.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/package.json +1 -1
- package/src/embeddings.ts +4 -0
- package/src/frontmatter.ts +1 -1
- package/src/journal.test.ts +61 -0
- package/src/journal.ts +58 -6
- package/src/memory.ts +1 -1
- package/src/plugin.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghilteras/opencode-agent-memory",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Letta-style editable memory blocks + journal for OpenCode (maintained fork of opencode-agent-memory).",
|
|
5
5
|
"author": "Angelo Pantano <ghilteras@protonmail.com>",
|
|
6
6
|
"license": "MIT",
|
package/src/embeddings.ts
CHANGED
|
@@ -25,6 +25,10 @@ async function getPipeline(cacheDir?: string) {
|
|
|
25
25
|
return pipelinePromise;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export async function warmupEmbedder(cacheDir?: string): Promise<void> {
|
|
29
|
+
await getPipeline(cacheDir);
|
|
30
|
+
}
|
|
31
|
+
|
|
28
32
|
export async function generateEmbedding(
|
|
29
33
|
text: string,
|
|
30
34
|
cacheDir?: string,
|
package/src/frontmatter.ts
CHANGED
package/src/journal.test.ts
CHANGED
|
@@ -401,6 +401,9 @@ describe("journal store", () => {
|
|
|
401
401
|
body: "unique-stale-dim-content",
|
|
402
402
|
});
|
|
403
403
|
|
|
404
|
+
// First search: caches the entry with the correct mock embedding.
|
|
405
|
+
await store.search({ text: "unique-stale-dim-content" });
|
|
406
|
+
|
|
404
407
|
// Simulate a v2 embedding with a WRONG dimension (e.g. from a 768d model).
|
|
405
408
|
// Search must not throw: it should skip the embedding and fall back to
|
|
406
409
|
// text matching for this entry.
|
|
@@ -424,6 +427,64 @@ describe("journal store", () => {
|
|
|
424
427
|
// No throw above is the assertion: mismatched dimension degrades to text.
|
|
425
428
|
});
|
|
426
429
|
|
|
430
|
+
test("sidecar rewrite after cache warm is picked up by second search", async () => {
|
|
431
|
+
tmpDir = await mkTmpDir();
|
|
432
|
+
const store = createJournalStore(tmpDir);
|
|
433
|
+
|
|
434
|
+
const entry = await store.write({
|
|
435
|
+
title: "Sidecar invalidation",
|
|
436
|
+
body: "unique-sidecar-invalidation-content",
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// First search: caches the entry + correct sidecar fingerprint.
|
|
440
|
+
const r1 = await store.search({ text: "unique-sidecar-invalidation-content" });
|
|
441
|
+
expect(r1.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
|
|
442
|
+
|
|
443
|
+
// Rewrite the sidecar with a wrong-dimension embedding (simulating
|
|
444
|
+
// sidecar regeneration without touching the .md).
|
|
445
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
446
|
+
await fs.writeFile(
|
|
447
|
+
embeddingFile,
|
|
448
|
+
JSON.stringify({
|
|
449
|
+
v: 2,
|
|
450
|
+
model: "some-768d-model",
|
|
451
|
+
dimension: 768,
|
|
452
|
+
vector: Array.from({ length: 768 }, () => 0.1),
|
|
453
|
+
}),
|
|
454
|
+
"utf-8",
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
// Second search must invalidate the cache (sidecar mtime changed) and
|
|
458
|
+
// pick up the stale-dimension embedding, falling back to text match.
|
|
459
|
+
const r2 = await store.search({ text: "unique-sidecar-invalidation-content" });
|
|
460
|
+
expect(r2.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("sidecar deleted between searches falls back to text match", async () => {
|
|
464
|
+
tmpDir = await mkTmpDir();
|
|
465
|
+
const store = createJournalStore(tmpDir);
|
|
466
|
+
|
|
467
|
+
const entry = await store.write({
|
|
468
|
+
title: "Sidecar deleted entry",
|
|
469
|
+
body: "unique-sidecar-deleted-content",
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// First search: caches the entry + sidecar fingerprint.
|
|
473
|
+
const r1 = await store.search({ text: "unique-sidecar-deleted-content" });
|
|
474
|
+
expect(r1.entries.some((e) => e.title === "Sidecar deleted entry")).toBe(true);
|
|
475
|
+
|
|
476
|
+
// Delete the sidecar file.
|
|
477
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
478
|
+
await fs.unlink(embeddingFile);
|
|
479
|
+
|
|
480
|
+
// Second search: cache miss (sidecar gone), falls back to text match
|
|
481
|
+
// (score 0.5) rather than using the stale cached embedding.
|
|
482
|
+
const r2 = await store.search({ text: "unique-sidecar-deleted-content" });
|
|
483
|
+
const found = r2.entries.find((e) => e.title === "Sidecar deleted entry");
|
|
484
|
+
expect(found).toBeDefined();
|
|
485
|
+
// Score is 0.5 (text fallback), not a cosine score
|
|
486
|
+
});
|
|
487
|
+
|
|
427
488
|
test("search skips corrupt v2 embedding (empty vector) without throwing", async () => {
|
|
428
489
|
tmpDir = await mkTmpDir();
|
|
429
490
|
const store = createJournalStore(tmpDir);
|
package/src/journal.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
|
|
5
|
-
import yaml from "js-yaml";
|
|
5
|
+
import * as yaml from "js-yaml";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
8
|
import { cosineSimilarity, EMBEDDING_DIMENSION, embeddingModelName, generateEmbedding } from "./embeddings";
|
|
@@ -122,6 +122,15 @@ type ParsedEmbedding =
|
|
|
122
122
|
| { vector: number[]; model: string; dimension: number }
|
|
123
123
|
| undefined;
|
|
124
124
|
|
|
125
|
+
type CachedEntry = {
|
|
126
|
+
entryMtimeMs: number;
|
|
127
|
+
entrySize: number;
|
|
128
|
+
embMtimeMs: number;
|
|
129
|
+
embSize: number;
|
|
130
|
+
entry: JournalEntry;
|
|
131
|
+
embedding: ParsedEmbedding;
|
|
132
|
+
};
|
|
133
|
+
|
|
125
134
|
async function loadEmbedding(entryPath: string): Promise<ParsedEmbedding> {
|
|
126
135
|
const ePath = embeddingPath(entryPath);
|
|
127
136
|
try {
|
|
@@ -197,6 +206,11 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
197
206
|
"journal",
|
|
198
207
|
);
|
|
199
208
|
|
|
209
|
+
// Per-store cache: each store instance owns its entries, so stores with
|
|
210
|
+
// different (including nested or ephemeral) journal directories never
|
|
211
|
+
// interfere and no foreign keys can accumulate.
|
|
212
|
+
const entryCache = new Map<string, CachedEntry>();
|
|
213
|
+
|
|
200
214
|
return {
|
|
201
215
|
async write(entry) {
|
|
202
216
|
await fs.mkdir(journalDir, { recursive: true });
|
|
@@ -288,6 +302,12 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
288
302
|
return { entries: [], total: 0, allTags: [] };
|
|
289
303
|
}
|
|
290
304
|
|
|
305
|
+
// Prune cache entries for files that no longer exist
|
|
306
|
+
const alive = new Set(files.map((f) => path.join(journalDir, f)));
|
|
307
|
+
for (const key of entryCache.keys()) {
|
|
308
|
+
if (!alive.has(key)) entryCache.delete(key);
|
|
309
|
+
}
|
|
310
|
+
|
|
291
311
|
// If a text query is provided, try semantic search first
|
|
292
312
|
let queryEmbedding: number[] | undefined;
|
|
293
313
|
if (query.text) {
|
|
@@ -301,14 +321,46 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
301
321
|
// Collect all tags across every entry (before filtering)
|
|
302
322
|
const tagSet = new Set<string>();
|
|
303
323
|
|
|
304
|
-
|
|
324
|
+
const indexed = await Promise.all(files.map(async (file) => {
|
|
305
325
|
const filePath = path.join(journalDir, file);
|
|
306
|
-
let entry: JournalEntry;
|
|
307
326
|
try {
|
|
308
|
-
|
|
327
|
+
const ePath = embeddingPath(filePath);
|
|
328
|
+
const [entryStat, embStat] = await Promise.all([
|
|
329
|
+
fs.stat(filePath),
|
|
330
|
+
fs.stat(ePath).catch(() => ({ mtimeMs: -1, size: -1 } as const)),
|
|
331
|
+
]);
|
|
332
|
+
const cached = entryCache.get(filePath);
|
|
333
|
+
if (
|
|
334
|
+
cached &&
|
|
335
|
+
cached.entryMtimeMs === entryStat.mtimeMs &&
|
|
336
|
+
cached.entrySize === entryStat.size &&
|
|
337
|
+
cached.embMtimeMs === embStat.mtimeMs &&
|
|
338
|
+
cached.embSize === embStat.size
|
|
339
|
+
) {
|
|
340
|
+
return cached;
|
|
341
|
+
}
|
|
342
|
+
const [entry, embedding] = await Promise.all([
|
|
343
|
+
readEntryFile(filePath),
|
|
344
|
+
loadEmbedding(filePath),
|
|
345
|
+
]);
|
|
346
|
+
const value: CachedEntry = {
|
|
347
|
+
entryMtimeMs: entryStat.mtimeMs,
|
|
348
|
+
entrySize: entryStat.size,
|
|
349
|
+
embMtimeMs: embStat.mtimeMs,
|
|
350
|
+
embSize: embStat.size,
|
|
351
|
+
entry,
|
|
352
|
+
embedding,
|
|
353
|
+
};
|
|
354
|
+
entryCache.set(filePath, value);
|
|
355
|
+
return value;
|
|
309
356
|
} catch {
|
|
310
|
-
|
|
357
|
+
return undefined;
|
|
311
358
|
}
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
for (const cached of indexed) {
|
|
362
|
+
if (!cached) continue;
|
|
363
|
+
const { entry } = cached;
|
|
312
364
|
|
|
313
365
|
// Collect tags before applying filters
|
|
314
366
|
for (const tag of entry.tags) {
|
|
@@ -333,7 +385,7 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
333
385
|
if (query.text) {
|
|
334
386
|
if (queryEmbedding) {
|
|
335
387
|
// Semantic search
|
|
336
|
-
const storedEmbedding =
|
|
388
|
+
const storedEmbedding = cached.embedding;
|
|
337
389
|
if (storedEmbedding) {
|
|
338
390
|
// Hard gate: dimension must match, or the embedding is stale
|
|
339
391
|
// (different model) and cannot be compared with cosine.
|
package/src/memory.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
|
|
5
|
-
import yaml from "js-yaml";
|
|
5
|
+
import * as yaml from "js-yaml";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
|
|
8
8
|
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
|
package/src/plugin.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
MemorySet,
|
|
17
17
|
} from "./tools";
|
|
18
18
|
import type { JournalContext } from "./tools";
|
|
19
|
+
import { warmupEmbedder } from "./embeddings";
|
|
19
20
|
|
|
20
21
|
export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
21
22
|
const store = createMemoryStore(directory);
|
|
@@ -37,6 +38,9 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
37
38
|
|
|
38
39
|
if (journalEnabled) {
|
|
39
40
|
const journalStore = createJournalStore(undefined, config.cacheDir);
|
|
41
|
+
// Warmup the embedder in the background; pass configured cacheDir so the
|
|
42
|
+
// first-init-wins singleton caches to the correct directory.
|
|
43
|
+
void warmupEmbedder(config.cacheDir).catch(() => {});
|
|
40
44
|
journalTools = {
|
|
41
45
|
journal_write: JournalWrite(journalStore, journalCtx),
|
|
42
46
|
journal_read: JournalRead(journalStore),
|