@ghilteras/opencode-agent-memory 0.3.1 → 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/README.md +19 -3
- package/package.json +1 -1
- package/src/embeddings.test.ts +47 -0
- package/src/embeddings.ts +28 -5
- package/src/frontmatter.ts +1 -1
- package/src/journal.test.ts +187 -2
- package/src/journal.ts +115 -15
- package/src/memory.ts +1 -1
- package/src/plugin.ts +5 -1
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# opencode-agent-memory
|
|
2
2
|
|
|
3
|
-
> **Fork notice**: This is the maintained fork `@ghilteras/opencode-agent-memory` v0.
|
|
3
|
+
> **Fork notice**: This is the maintained fork `@ghilteras/opencode-agent-memory` v0.4.0 of
|
|
4
4
|
> [opencode-agent-memory](https://github.com/joshuadavidthomas/opencode-agent-memory)
|
|
5
5
|
> (joshuadavidthomas, MIT). This release adopts the "proper timestamps without cache
|
|
6
6
|
> busting" design (per-block `modified_at` frontmatter, remove the volatile
|
|
7
7
|
> `memory_metadata` block, append memory XML at the end of the system prompt) derived
|
|
8
8
|
> from the Annakan/draxxris fork plan. Upstream PR #20 (freeze metadata timestamps) is
|
|
9
|
-
> superseded by the stronger per-block design and remains open upstream.
|
|
9
|
+
> superseded by the stronger per-block design and remains open upstream. v0.4.0 swaps
|
|
10
|
+
> the embedding model to a multilingual one and adds a versioned embedding format.
|
|
10
11
|
|
|
11
12
|
[Letta](https://letta.com)-style editable [memory blocks](https://docs.letta.com/guides/agents/memory-blocks/) for [OpenCode](https://opencode.ai).
|
|
12
13
|
|
|
@@ -86,7 +87,22 @@ When the journal is enabled, the agent gets 3 additional tools:
|
|
|
86
87
|
| `journal_search` | Search entries semantically, filter by project or tags, with pagination |
|
|
87
88
|
| `journal_read` | Read a specific journal entry by ID |
|
|
88
89
|
|
|
89
|
-
Journal entries are append-only markdown files with YAML frontmatter, stored in `~/.config/opencode/journal/`. Each entry records which project, model, provider, agent, and session it was written from. Semantic search uses local embeddings ([
|
|
90
|
+
Journal entries are append-only markdown files with YAML frontmatter, stored in `~/.config/opencode/journal/`. Each entry records which project, model, provider, agent, and session it was written from. Semantic search uses local embeddings ([paraphrase-multilingual-MiniLM-L12-v2](https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2), 384d, multilingual EN/IT) - no data leaves your machine.
|
|
91
|
+
|
|
92
|
+
Embedding files (`.embedding`, written alongside each entry) use a versioned format (`{ v: 2, model, dimension, vector }`) so that stale or mismatched embeddings are detected: if the stored dimension doesn't match the current model, the entry falls back to text matching instead of failing the search. Legacy bare-array embeddings from v0.3.x remain readable.
|
|
93
|
+
|
|
94
|
+
### cacheDir Configuration
|
|
95
|
+
|
|
96
|
+
By default the transformers.js model cache lives in the default location (`~/.cache/huggingface`). You can relocate it by setting `cacheDir` at the top level of `~/.config/opencode/agent-memory.json`:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"cacheDir": "/home/angelo/.cache/opencode/memory-model",
|
|
101
|
+
"journal": {
|
|
102
|
+
"enabled": true
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
90
106
|
|
|
91
107
|
### Default Blocks
|
|
92
108
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghilteras/opencode-agent-memory",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -0,0 +1,47 @@
|
|
|
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/embeddings.ts
CHANGED
|
@@ -1,24 +1,47 @@
|
|
|
1
|
-
const MODEL_NAME = "Xenova/
|
|
1
|
+
export const MODEL_NAME = "Xenova/paraphrase-multilingual-MiniLM-L12-v2";
|
|
2
|
+
export const EMBEDDING_DIMENSION = 384;
|
|
2
3
|
const MODEL_DTYPE = "q8";
|
|
3
4
|
|
|
5
|
+
export function getEmbeddingDimension(): number {
|
|
6
|
+
return EMBEDDING_DIMENSION;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function embeddingModelName(): string {
|
|
10
|
+
return MODEL_NAME;
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
let pipelinePromise: Promise<any> | undefined;
|
|
5
14
|
|
|
6
|
-
async function getPipeline() {
|
|
15
|
+
async function getPipeline(cacheDir?: string) {
|
|
7
16
|
if (!pipelinePromise) {
|
|
8
17
|
pipelinePromise = (async () => {
|
|
9
18
|
const { pipeline } = await import("@huggingface/transformers");
|
|
10
19
|
return pipeline("feature-extraction", MODEL_NAME, {
|
|
11
20
|
dtype: MODEL_DTYPE,
|
|
21
|
+
cache_dir: cacheDir,
|
|
12
22
|
});
|
|
13
23
|
})();
|
|
14
24
|
}
|
|
15
25
|
return pipelinePromise;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
|
-
export async function
|
|
19
|
-
|
|
28
|
+
export async function warmupEmbedder(cacheDir?: string): Promise<void> {
|
|
29
|
+
await getPipeline(cacheDir);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function generateEmbedding(
|
|
33
|
+
text: string,
|
|
34
|
+
cacheDir?: string,
|
|
35
|
+
): Promise<number[]> {
|
|
36
|
+
const pipe = await getPipeline(cacheDir);
|
|
20
37
|
const output = await pipe(text, { pooling: "mean", normalize: true });
|
|
21
|
-
|
|
38
|
+
const out = Array.from(output.data as Float32Array) as number[];
|
|
39
|
+
if (out.length !== EMBEDDING_DIMENSION) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Embedding dimension mismatch: expected ${EMBEDDING_DIMENSION}, got ${out.length}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
22
45
|
}
|
|
23
46
|
|
|
24
47
|
export function cosineSimilarity(a: number[], b: number[]): number {
|
package/src/frontmatter.ts
CHANGED
package/src/journal.test.ts
CHANGED
|
@@ -14,7 +14,13 @@ mock.module("./embeddings", () => ({
|
|
|
14
14
|
);
|
|
15
15
|
return Array.from({ length: 8 }, (_, i) => Math.sin(hash + i));
|
|
16
16
|
},
|
|
17
|
+
EMBEDDING_DIMENSION: 8,
|
|
18
|
+
embeddingModelName: () => "test-model",
|
|
17
19
|
cosineSimilarity: (a: number[], b: number[]) => {
|
|
20
|
+
if (a.length !== b.length)
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Embedding dimension mismatch: ${a.length} vs ${b.length}`,
|
|
23
|
+
);
|
|
18
24
|
let dot = 0;
|
|
19
25
|
let normA = 0;
|
|
20
26
|
let normB = 0;
|
|
@@ -149,8 +155,12 @@ describe("journal store", () => {
|
|
|
149
155
|
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
150
156
|
const raw = await fs.readFile(embeddingFile, "utf-8");
|
|
151
157
|
const embedding = JSON.parse(raw);
|
|
152
|
-
|
|
153
|
-
expect(embedding.
|
|
158
|
+
// v0.4.0: versioned embedding object, not a bare array
|
|
159
|
+
expect(embedding.v).toBe(2);
|
|
160
|
+
expect(embedding.model).toBe("test-model");
|
|
161
|
+
expect(embedding.dimension).toBe(8);
|
|
162
|
+
expect(Array.isArray(embedding.vector)).toBe(true);
|
|
163
|
+
expect(embedding.vector.length).toBeGreaterThan(0);
|
|
154
164
|
});
|
|
155
165
|
|
|
156
166
|
test("read returns full entry by id", async () => {
|
|
@@ -381,4 +391,179 @@ describe("journal store", () => {
|
|
|
381
391
|
// Should find at least the matching entry
|
|
382
392
|
expect(result.total).toBeGreaterThan(0);
|
|
383
393
|
});
|
|
394
|
+
|
|
395
|
+
test("search skips stale-dimension embedding and falls back to text match", async () => {
|
|
396
|
+
tmpDir = await mkTmpDir();
|
|
397
|
+
const store = createJournalStore(tmpDir);
|
|
398
|
+
|
|
399
|
+
const entry = await store.write({
|
|
400
|
+
title: "Stale dims entry",
|
|
401
|
+
body: "unique-stale-dim-content",
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// First search: caches the entry with the correct mock embedding.
|
|
405
|
+
await store.search({ text: "unique-stale-dim-content" });
|
|
406
|
+
|
|
407
|
+
// Simulate a v2 embedding with a WRONG dimension (e.g. from a 768d model).
|
|
408
|
+
// Search must not throw: it should skip the embedding and fall back to
|
|
409
|
+
// text matching for this entry.
|
|
410
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
411
|
+
await fs.writeFile(
|
|
412
|
+
embeddingFile,
|
|
413
|
+
JSON.stringify({
|
|
414
|
+
v: 2,
|
|
415
|
+
model: "some-768d-model",
|
|
416
|
+
dimension: 768,
|
|
417
|
+
vector: Array.from({ length: 768 }, () => 0.1),
|
|
418
|
+
}),
|
|
419
|
+
"utf-8",
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// Query whose embedding would collide with the stale one if cosine were
|
|
423
|
+
// attempted (dimension mismatch would throw inside cosineSimilarity).
|
|
424
|
+
const result = await store.search({ text: "unique-stale-dim-content" });
|
|
425
|
+
const titles = result.entries.map((e) => e.title);
|
|
426
|
+
expect(titles).toContain("Stale dims entry");
|
|
427
|
+
// No throw above is the assertion: mismatched dimension degrades to text.
|
|
428
|
+
});
|
|
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
|
+
|
|
488
|
+
test("search skips corrupt v2 embedding (empty vector) without throwing", async () => {
|
|
489
|
+
tmpDir = await mkTmpDir();
|
|
490
|
+
const store = createJournalStore(tmpDir);
|
|
491
|
+
|
|
492
|
+
const entry = await store.write({
|
|
493
|
+
title: "Corrupt embedding entry",
|
|
494
|
+
body: "unique-corrupt-embedding-content",
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Simulate a corrupt-but-well-formed v2 embedding: dimension claims 384
|
|
498
|
+
// but the vector is empty. Search must NOT throw — it should skip the
|
|
499
|
+
// embedding and fall back to text matching for this entry.
|
|
500
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
501
|
+
await fs.writeFile(
|
|
502
|
+
embeddingFile,
|
|
503
|
+
JSON.stringify({ v: 2, model: "some-model", dimension: 8, vector: [] }),
|
|
504
|
+
"utf-8",
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const result = await store.search({ text: "unique-corrupt-embedding-content" });
|
|
508
|
+
const titles = result.entries.map((e) => e.title);
|
|
509
|
+
expect(titles).toContain("Corrupt embedding entry");
|
|
510
|
+
// No throw above is the assertion: corrupt v2 embedding degrades to text.
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("search skips corrupt v2 embedding (missing vector) without throwing", async () => {
|
|
514
|
+
tmpDir = await mkTmpDir();
|
|
515
|
+
const store = createJournalStore(tmpDir);
|
|
516
|
+
|
|
517
|
+
const entry = await store.write({
|
|
518
|
+
title: "Missing vector entry",
|
|
519
|
+
body: "unique-missing-vector-content",
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// v2 file that omits the vector field entirely. Search must not throw.
|
|
523
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
524
|
+
await fs.writeFile(
|
|
525
|
+
embeddingFile,
|
|
526
|
+
JSON.stringify({ v: 2, model: "some-model", dimension: 8 }),
|
|
527
|
+
"utf-8",
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const result = await store.search({ text: "unique-missing-vector-content" });
|
|
531
|
+
const titles = result.entries.map((e) => e.title);
|
|
532
|
+
expect(titles).toContain("Missing vector entry");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("legacy bare-array embedding is still usable for semantic search", async () => {
|
|
536
|
+
tmpDir = await mkTmpDir();
|
|
537
|
+
const store = createJournalStore(tmpDir);
|
|
538
|
+
|
|
539
|
+
const entry = await store.write({
|
|
540
|
+
title: "Legacy embedding",
|
|
541
|
+
body: "legacy-vector-body",
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Overwrite with a legacy v1 bare-array embedding (from all-MiniLM-L6-v2,
|
|
545
|
+
// which also produces 8-dim vectors under the test mock). Use the same
|
|
546
|
+
// mock formula as generateEmbedding so the cosine is positive and the
|
|
547
|
+
// entry is actually matched via semantic search.
|
|
548
|
+
const hash = Array.from("legacy-vector-body").reduce(
|
|
549
|
+
(acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
|
|
550
|
+
0,
|
|
551
|
+
);
|
|
552
|
+
const legacyVector = Array.from(
|
|
553
|
+
{ length: 8 },
|
|
554
|
+
(_, i) => Math.sin(hash + i),
|
|
555
|
+
);
|
|
556
|
+
const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
|
|
557
|
+
await fs.writeFile(
|
|
558
|
+
embeddingFile,
|
|
559
|
+
JSON.stringify(legacyVector),
|
|
560
|
+
"utf-8",
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
// Legacy array must be treated as { vector, model: "unknown-legacy",
|
|
564
|
+
// dimension: length }; with a matching dimension it stays usable.
|
|
565
|
+
const result = await store.search({ text: "legacy-vector-body" });
|
|
566
|
+
const titles = result.entries.map((e) => e.title);
|
|
567
|
+
expect(titles).toContain("Legacy embedding");
|
|
568
|
+
});
|
|
384
569
|
});
|
package/src/journal.ts
CHANGED
|
@@ -2,10 +2,10 @@ 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
|
-
import { cosineSimilarity, generateEmbedding } from "./embeddings";
|
|
8
|
+
import { cosineSimilarity, EMBEDDING_DIMENSION, embeddingModelName, generateEmbedding } from "./embeddings";
|
|
9
9
|
import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
|
|
10
10
|
|
|
11
11
|
const TagSchema = z.looseObject({
|
|
@@ -14,6 +14,7 @@ const TagSchema = z.looseObject({
|
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
const ConfigSchema = z.looseObject({
|
|
17
|
+
cacheDir: z.string().optional(),
|
|
17
18
|
journal: z
|
|
18
19
|
.looseObject({
|
|
19
20
|
enabled: z.boolean().optional(),
|
|
@@ -117,11 +118,50 @@ async function readEntryFile(filePath: string): Promise<JournalEntry> {
|
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
|
|
121
|
+
type ParsedEmbedding =
|
|
122
|
+
| { vector: number[]; model: string; dimension: number }
|
|
123
|
+
| undefined;
|
|
124
|
+
|
|
125
|
+
type CachedEntry = {
|
|
126
|
+
entryMtimeMs: number;
|
|
127
|
+
entrySize: number;
|
|
128
|
+
embMtimeMs: number;
|
|
129
|
+
embSize: number;
|
|
130
|
+
entry: JournalEntry;
|
|
131
|
+
embedding: ParsedEmbedding;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
async function loadEmbedding(entryPath: string): Promise<ParsedEmbedding> {
|
|
121
135
|
const ePath = embeddingPath(entryPath);
|
|
122
136
|
try {
|
|
123
137
|
const raw = await fs.readFile(ePath, "utf-8");
|
|
124
|
-
|
|
138
|
+
const parsed = JSON.parse(raw);
|
|
139
|
+
if (Array.isArray(parsed)) {
|
|
140
|
+
// Legacy v1 (bare array from all-MiniLM-L6-v2)
|
|
141
|
+
return {
|
|
142
|
+
vector: parsed as number[],
|
|
143
|
+
model: "unknown-legacy",
|
|
144
|
+
dimension: parsed.length,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (parsed && typeof parsed === "object" && parsed.v === 2) {
|
|
148
|
+
const vector = Array.isArray(parsed.vector)
|
|
149
|
+
? parsed.vector
|
|
150
|
+
: undefined;
|
|
151
|
+
if (
|
|
152
|
+
vector &&
|
|
153
|
+
vector.length === (parsed.dimension as number) &&
|
|
154
|
+
vector.length > 0
|
|
155
|
+
) {
|
|
156
|
+
return {
|
|
157
|
+
vector,
|
|
158
|
+
model: parsed.model as string,
|
|
159
|
+
dimension: parsed.dimension as number,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
125
165
|
} catch {
|
|
126
166
|
return undefined;
|
|
127
167
|
}
|
|
@@ -160,12 +200,17 @@ export type JournalStore = {
|
|
|
160
200
|
}): Promise<{ entries: JournalEntry[]; total: number; allTags: string[] }>;
|
|
161
201
|
};
|
|
162
202
|
|
|
163
|
-
export function createJournalStore(configDir?: string): JournalStore {
|
|
203
|
+
export function createJournalStore(configDir?: string, cacheDir?: string): JournalStore {
|
|
164
204
|
const journalDir = path.join(
|
|
165
205
|
configDir ?? path.join(os.homedir(), ".config", "opencode"),
|
|
166
206
|
"journal",
|
|
167
207
|
);
|
|
168
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
|
+
|
|
169
214
|
return {
|
|
170
215
|
async write(entry) {
|
|
171
216
|
await fs.mkdir(journalDir, { recursive: true });
|
|
@@ -192,10 +237,15 @@ export function createJournalStore(configDir?: string): JournalStore {
|
|
|
192
237
|
// Generate and save embedding for semantic search
|
|
193
238
|
const searchableText = `${entry.title}\n${entry.body}`;
|
|
194
239
|
try {
|
|
195
|
-
const embedding = await generateEmbedding(searchableText);
|
|
240
|
+
const embedding = await generateEmbedding(searchableText, cacheDir);
|
|
196
241
|
await fs.writeFile(
|
|
197
242
|
embeddingPath(filePath),
|
|
198
|
-
JSON.stringify(
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
v: 2, // schema version for the embedding file
|
|
245
|
+
model: embeddingModelName(), // the embedding model that produced it
|
|
246
|
+
dimension: EMBEDDING_DIMENSION,
|
|
247
|
+
vector: embedding, // the actual float array
|
|
248
|
+
}),
|
|
199
249
|
"utf-8",
|
|
200
250
|
);
|
|
201
251
|
} catch {
|
|
@@ -252,11 +302,17 @@ export function createJournalStore(configDir?: string): JournalStore {
|
|
|
252
302
|
return { entries: [], total: 0, allTags: [] };
|
|
253
303
|
}
|
|
254
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
|
+
|
|
255
311
|
// If a text query is provided, try semantic search first
|
|
256
312
|
let queryEmbedding: number[] | undefined;
|
|
257
313
|
if (query.text) {
|
|
258
314
|
try {
|
|
259
|
-
queryEmbedding = await generateEmbedding(query.text);
|
|
315
|
+
queryEmbedding = await generateEmbedding(query.text, cacheDir);
|
|
260
316
|
} catch {
|
|
261
317
|
// Fall back to text search if embedding fails
|
|
262
318
|
}
|
|
@@ -265,14 +321,46 @@ export function createJournalStore(configDir?: string): JournalStore {
|
|
|
265
321
|
// Collect all tags across every entry (before filtering)
|
|
266
322
|
const tagSet = new Set<string>();
|
|
267
323
|
|
|
268
|
-
|
|
324
|
+
const indexed = await Promise.all(files.map(async (file) => {
|
|
269
325
|
const filePath = path.join(journalDir, file);
|
|
270
|
-
let entry: JournalEntry;
|
|
271
326
|
try {
|
|
272
|
-
|
|
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;
|
|
273
356
|
} catch {
|
|
274
|
-
|
|
357
|
+
return undefined;
|
|
275
358
|
}
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
for (const cached of indexed) {
|
|
362
|
+
if (!cached) continue;
|
|
363
|
+
const { entry } = cached;
|
|
276
364
|
|
|
277
365
|
// Collect tags before applying filters
|
|
278
366
|
for (const tag of entry.tags) {
|
|
@@ -297,9 +385,21 @@ export function createJournalStore(configDir?: string): JournalStore {
|
|
|
297
385
|
if (query.text) {
|
|
298
386
|
if (queryEmbedding) {
|
|
299
387
|
// Semantic search
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
-
|
|
388
|
+
const storedEmbedding = cached.embedding;
|
|
389
|
+
if (storedEmbedding) {
|
|
390
|
+
// Hard gate: dimension must match, or the embedding is stale
|
|
391
|
+
// (different model) and cannot be compared with cosine.
|
|
392
|
+
// A mismatched/stale embedding degrades gracefully to text match.
|
|
393
|
+
if (storedEmbedding.dimension !== EMBEDDING_DIMENSION) {
|
|
394
|
+
const haystack =
|
|
395
|
+
`${entry.title}\n${entry.body}`.toLowerCase();
|
|
396
|
+
score = haystack.includes(query.text.toLowerCase()) ? 0.5 : 0;
|
|
397
|
+
} else {
|
|
398
|
+
score = cosineSimilarity(
|
|
399
|
+
queryEmbedding,
|
|
400
|
+
storedEmbedding.vector,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
303
403
|
} else {
|
|
304
404
|
// No embedding stored; fall back to text match
|
|
305
405
|
const haystack =
|
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);
|
|
@@ -36,7 +37,10 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
36
37
|
let journalSystemNote = "";
|
|
37
38
|
|
|
38
39
|
if (journalEnabled) {
|
|
39
|
-
const journalStore = createJournalStore();
|
|
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),
|