@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 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.1",
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/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 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
- return Array.from(output.data as Float32Array) as number[];
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 {
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
 
4
- import yaml from "js-yaml";
4
+ import * as yaml from "js-yaml";
5
5
 
6
6
  export function splitFrontmatter(text: string): {
7
7
  frontmatterText: string | undefined;
@@ -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,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
- async function loadEmbedding(entryPath: string): Promise<number[] | undefined> {
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
- return JSON.parse(raw) as number[];
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(embedding),
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
- for (const file of files) {
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
- entry = await readEntryFile(filePath);
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
- continue;
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 entryEmbedding = await loadEmbedding(filePath);
301
- if (entryEmbedding) {
302
- score = cosineSimilarity(queryEmbedding, entryEmbedding);
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),