@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghilteras/opencode-agent-memory",
3
- "version": "0.4.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",
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,
@@ -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;
@@ -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
- for (const file of files) {
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
- 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;
309
356
  } catch {
310
- continue;
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 = await loadEmbedding(filePath);
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),