@ghilteras/opencode-agent-memory 0.3.0 → 0.4.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/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.0 of
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 ([all-MiniLM-L6-v2](https://huggingface.co/Xenova/all-MiniLM-L6-v2)) - no data leaves your machine.
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.0",
3
+ "version": "0.4.0",
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,43 @@
1
- const MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
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 generateEmbedding(text: string): Promise<number[]> {
19
- const pipe = await getPipeline();
28
+ export async function generateEmbedding(
29
+ text: string,
30
+ cacheDir?: string,
31
+ ): Promise<number[]> {
32
+ const pipe = await getPipeline(cacheDir);
20
33
  const output = await pipe(text, { pooling: "mean", normalize: true });
21
- return Array.from(output.data as Float32Array) as number[];
34
+ const out = Array.from(output.data as Float32Array) as number[];
35
+ if (out.length !== EMBEDDING_DIMENSION) {
36
+ throw new Error(
37
+ `Embedding dimension mismatch: expected ${EMBEDDING_DIMENSION}, got ${out.length}`,
38
+ );
39
+ }
40
+ return out;
22
41
  }
23
42
 
24
43
  export function cosineSimilarity(a: number[], b: number[]): number {
@@ -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
- expect(Array.isArray(embedding)).toBe(true);
153
- expect(embedding.length).toBeGreaterThan(0);
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,118 @@ 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
+ // Simulate a v2 embedding with a WRONG dimension (e.g. from a 768d model).
405
+ // Search must not throw: it should skip the embedding and fall back to
406
+ // text matching for this entry.
407
+ const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
408
+ await fs.writeFile(
409
+ embeddingFile,
410
+ JSON.stringify({
411
+ v: 2,
412
+ model: "some-768d-model",
413
+ dimension: 768,
414
+ vector: Array.from({ length: 768 }, () => 0.1),
415
+ }),
416
+ "utf-8",
417
+ );
418
+
419
+ // Query whose embedding would collide with the stale one if cosine were
420
+ // attempted (dimension mismatch would throw inside cosineSimilarity).
421
+ const result = await store.search({ text: "unique-stale-dim-content" });
422
+ const titles = result.entries.map((e) => e.title);
423
+ expect(titles).toContain("Stale dims entry");
424
+ // No throw above is the assertion: mismatched dimension degrades to text.
425
+ });
426
+
427
+ test("search skips corrupt v2 embedding (empty vector) without throwing", async () => {
428
+ tmpDir = await mkTmpDir();
429
+ const store = createJournalStore(tmpDir);
430
+
431
+ const entry = await store.write({
432
+ title: "Corrupt embedding entry",
433
+ body: "unique-corrupt-embedding-content",
434
+ });
435
+
436
+ // Simulate a corrupt-but-well-formed v2 embedding: dimension claims 384
437
+ // but the vector is empty. Search must NOT throw — it should skip the
438
+ // embedding and fall back to text matching for this entry.
439
+ const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
440
+ await fs.writeFile(
441
+ embeddingFile,
442
+ JSON.stringify({ v: 2, model: "some-model", dimension: 8, vector: [] }),
443
+ "utf-8",
444
+ );
445
+
446
+ const result = await store.search({ text: "unique-corrupt-embedding-content" });
447
+ const titles = result.entries.map((e) => e.title);
448
+ expect(titles).toContain("Corrupt embedding entry");
449
+ // No throw above is the assertion: corrupt v2 embedding degrades to text.
450
+ });
451
+
452
+ test("search skips corrupt v2 embedding (missing vector) without throwing", async () => {
453
+ tmpDir = await mkTmpDir();
454
+ const store = createJournalStore(tmpDir);
455
+
456
+ const entry = await store.write({
457
+ title: "Missing vector entry",
458
+ body: "unique-missing-vector-content",
459
+ });
460
+
461
+ // v2 file that omits the vector field entirely. Search must not throw.
462
+ const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
463
+ await fs.writeFile(
464
+ embeddingFile,
465
+ JSON.stringify({ v: 2, model: "some-model", dimension: 8 }),
466
+ "utf-8",
467
+ );
468
+
469
+ const result = await store.search({ text: "unique-missing-vector-content" });
470
+ const titles = result.entries.map((e) => e.title);
471
+ expect(titles).toContain("Missing vector entry");
472
+ });
473
+
474
+ test("legacy bare-array embedding is still usable for semantic search", async () => {
475
+ tmpDir = await mkTmpDir();
476
+ const store = createJournalStore(tmpDir);
477
+
478
+ const entry = await store.write({
479
+ title: "Legacy embedding",
480
+ body: "legacy-vector-body",
481
+ });
482
+
483
+ // Overwrite with a legacy v1 bare-array embedding (from all-MiniLM-L6-v2,
484
+ // which also produces 8-dim vectors under the test mock). Use the same
485
+ // mock formula as generateEmbedding so the cosine is positive and the
486
+ // entry is actually matched via semantic search.
487
+ const hash = Array.from("legacy-vector-body").reduce(
488
+ (acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
489
+ 0,
490
+ );
491
+ const legacyVector = Array.from(
492
+ { length: 8 },
493
+ (_, i) => Math.sin(hash + i),
494
+ );
495
+ const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
496
+ await fs.writeFile(
497
+ embeddingFile,
498
+ JSON.stringify(legacyVector),
499
+ "utf-8",
500
+ );
501
+
502
+ // Legacy array must be treated as { vector, model: "unknown-legacy",
503
+ // dimension: length }; with a matching dimension it stays usable.
504
+ const result = await store.search({ text: "legacy-vector-body" });
505
+ const titles = result.entries.map((e) => e.title);
506
+ expect(titles).toContain("Legacy embedding");
507
+ });
384
508
  });
package/src/journal.ts CHANGED
@@ -5,7 +5,7 @@ import * as path from "node:path";
5
5
  import 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,41 @@ async function readEntryFile(filePath: string): Promise<JournalEntry> {
117
118
  };
118
119
  }
119
120
 
120
- async function loadEmbedding(entryPath: string): Promise<number[] | undefined> {
121
+ type ParsedEmbedding =
122
+ | { vector: number[]; model: string; dimension: number }
123
+ | undefined;
124
+
125
+ async function loadEmbedding(entryPath: string): Promise<ParsedEmbedding> {
121
126
  const ePath = embeddingPath(entryPath);
122
127
  try {
123
128
  const raw = await fs.readFile(ePath, "utf-8");
124
- return JSON.parse(raw) as number[];
129
+ const parsed = JSON.parse(raw);
130
+ if (Array.isArray(parsed)) {
131
+ // Legacy v1 (bare array from all-MiniLM-L6-v2)
132
+ return {
133
+ vector: parsed as number[],
134
+ model: "unknown-legacy",
135
+ dimension: parsed.length,
136
+ };
137
+ }
138
+ if (parsed && typeof parsed === "object" && parsed.v === 2) {
139
+ const vector = Array.isArray(parsed.vector)
140
+ ? parsed.vector
141
+ : undefined;
142
+ if (
143
+ vector &&
144
+ vector.length === (parsed.dimension as number) &&
145
+ vector.length > 0
146
+ ) {
147
+ return {
148
+ vector,
149
+ model: parsed.model as string,
150
+ dimension: parsed.dimension as number,
151
+ };
152
+ }
153
+ return undefined;
154
+ }
155
+ return undefined;
125
156
  } catch {
126
157
  return undefined;
127
158
  }
@@ -160,7 +191,7 @@ export type JournalStore = {
160
191
  }): Promise<{ entries: JournalEntry[]; total: number; allTags: string[] }>;
161
192
  };
162
193
 
163
- export function createJournalStore(configDir?: string): JournalStore {
194
+ export function createJournalStore(configDir?: string, cacheDir?: string): JournalStore {
164
195
  const journalDir = path.join(
165
196
  configDir ?? path.join(os.homedir(), ".config", "opencode"),
166
197
  "journal",
@@ -192,10 +223,15 @@ export function createJournalStore(configDir?: string): JournalStore {
192
223
  // Generate and save embedding for semantic search
193
224
  const searchableText = `${entry.title}\n${entry.body}`;
194
225
  try {
195
- const embedding = await generateEmbedding(searchableText);
226
+ const embedding = await generateEmbedding(searchableText, cacheDir);
196
227
  await fs.writeFile(
197
228
  embeddingPath(filePath),
198
- JSON.stringify(embedding),
229
+ JSON.stringify({
230
+ v: 2, // schema version for the embedding file
231
+ model: embeddingModelName(), // the embedding model that produced it
232
+ dimension: EMBEDDING_DIMENSION,
233
+ vector: embedding, // the actual float array
234
+ }),
199
235
  "utf-8",
200
236
  );
201
237
  } catch {
@@ -256,7 +292,7 @@ export function createJournalStore(configDir?: string): JournalStore {
256
292
  let queryEmbedding: number[] | undefined;
257
293
  if (query.text) {
258
294
  try {
259
- queryEmbedding = await generateEmbedding(query.text);
295
+ queryEmbedding = await generateEmbedding(query.text, cacheDir);
260
296
  } catch {
261
297
  // Fall back to text search if embedding fails
262
298
  }
@@ -297,9 +333,21 @@ export function createJournalStore(configDir?: string): JournalStore {
297
333
  if (query.text) {
298
334
  if (queryEmbedding) {
299
335
  // Semantic search
300
- const entryEmbedding = await loadEmbedding(filePath);
301
- if (entryEmbedding) {
302
- score = cosineSimilarity(queryEmbedding, entryEmbedding);
336
+ const storedEmbedding = await loadEmbedding(filePath);
337
+ if (storedEmbedding) {
338
+ // Hard gate: dimension must match, or the embedding is stale
339
+ // (different model) and cannot be compared with cosine.
340
+ // A mismatched/stale embedding degrades gracefully to text match.
341
+ if (storedEmbedding.dimension !== EMBEDDING_DIMENSION) {
342
+ const haystack =
343
+ `${entry.title}\n${entry.body}`.toLowerCase();
344
+ score = haystack.includes(query.text.toLowerCase()) ? 0.5 : 0;
345
+ } else {
346
+ score = cosineSimilarity(
347
+ queryEmbedding,
348
+ storedEmbedding.vector,
349
+ );
350
+ }
303
351
  } else {
304
352
  // No embedding stored; fall back to text match
305
353
  const haystack =
package/src/plugin.ts CHANGED
@@ -36,7 +36,7 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
36
36
  let journalSystemNote = "";
37
37
 
38
38
  if (journalEnabled) {
39
- const journalStore = createJournalStore();
39
+ const journalStore = createJournalStore(undefined, config.cacheDir);
40
40
  journalTools = {
41
41
  journal_write: JournalWrite(journalStore, journalCtx),
42
42
  journal_read: JournalRead(journalStore),