@ghilteras/opencode-agent-memory 1.0.0 → 2.0.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.
@@ -1,45 +1,48 @@
1
- import { afterEach, describe, expect, mock, test } from "bun:test";
1
+ import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import * as realEmbeddings from "./embeddings";
5
+ import NativeMiniSearch from "minisearch";
4
6
 
5
- import { createJournalStore, loadConfig } from "./journal";
7
+ let embeddingGenerationCalls = 0;
8
+ let lexicalIndexConstructions = 0;
6
9
 
7
- // Mock the embeddings module to avoid downloading a real model in tests
10
+ class CountingMiniSearch extends (NativeMiniSearch as any) {
11
+ constructor(...args: any[]) {
12
+ super(...args);
13
+ lexicalIndexConstructions++;
14
+ }
15
+ }
16
+
17
+ mock.module("minisearch", () => ({ default: CountingMiniSearch }));
18
+
19
+ // Mock the embeddings module to avoid downloading a real model in tests.
20
+ // Spread the real module so every export not overridden here keeps its real
21
+ // value regardless of test-file discovery order.
8
22
  mock.module("./embeddings", () => ({
9
- generateEmbedding: async (text: string) => {
10
- // Deterministic fake embedding based on text content
11
- const hash = Array.from(text).reduce(
12
- (acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
13
- 0,
14
- );
15
- return Array.from({ length: 8 }, (_, i) => Math.sin(hash + i));
23
+ ...realEmbeddings,
24
+ generateEmbedding: async () => {
25
+ embeddingGenerationCalls++;
26
+ return Array.from({ length: 8 }, (_, i) => i / 8);
16
27
  },
17
28
  EMBEDDING_DIMENSION: 8,
18
29
  embeddingModelName: () => "test-model",
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
- );
24
- let dot = 0;
25
- let normA = 0;
26
- let normB = 0;
27
- for (let i = 0; i < a.length; i++) {
28
- const ai = a[i]!;
29
- const bi = b[i]!;
30
- dot += ai * bi;
31
- normA += ai * ai;
32
- normB += bi * bi;
33
- }
34
- const denom = Math.sqrt(normA) * Math.sqrt(normB);
35
- return denom === 0 ? 0 : dot / denom;
36
- },
37
30
  }));
38
31
 
39
32
  async function mkTmpDir(): Promise<string> {
40
33
  return fs.mkdtemp(path.join("/tmp/", "opencode-journal-"));
41
34
  }
42
35
 
36
+ // Bind the journal module AFTER the embeddings mock registration (above) via
37
+ // dynamic import, so the store uses the fake embeddings regardless of bun's
38
+ // module-mock rewiring semantics; a static import would be hoisted above it.
39
+ let createJournalStore!: (typeof import("./journal"))["createJournalStore"];
40
+ let loadConfig!: (typeof import("./journal"))["loadConfig"];
41
+
42
+ beforeAll(async () => {
43
+ ({ createJournalStore, loadConfig } = await import("./journal"));
44
+ });
45
+
43
46
  describe("loadConfig", () => {
44
47
  test("classifies a missing file and returns an empty config", async () => {
45
48
  const dir = await mkTmpDir();
@@ -131,6 +134,8 @@ describe("journal store", () => {
131
134
  let tmpDir: string;
132
135
 
133
136
  afterEach(async () => {
137
+ embeddingGenerationCalls = 0;
138
+ lexicalIndexConstructions = 0;
134
139
  if (tmpDir) {
135
140
  await fs.rm(tmpDir, { recursive: true, force: true });
136
141
  }
@@ -403,7 +408,7 @@ describe("journal store", () => {
403
408
  expect(result.allTags).toEqual(["debugging", "perf", "rust"]);
404
409
  });
405
410
 
406
- test("search by text uses semantic matching", async () => {
411
+ test("search by text uses lexical OR matching", async () => {
407
412
  tmpDir = await mkTmpDir();
408
413
  const store = createJournalStore(tmpDir);
409
414
 
@@ -417,13 +422,11 @@ describe("journal store", () => {
417
422
  body: "Set up pytest with coverage reporting.",
418
423
  });
419
424
 
420
- // With mock embeddings, text search falls through to semantic matching
421
- const result = await store.search({ text: "Rust performance" });
422
- // Should find at least the matching entry
423
- expect(result.total).toBeGreaterThan(0);
425
+ const result = await store.search({ text: "Rust absentterm" });
426
+ expect(result.entries.map((entry) => entry.title)).toEqual(["Rust performance"]);
424
427
  });
425
428
 
426
- test("title query surfaces long-body entry despite diluted embedding", async () => {
429
+ test("title query matches terms from the title", async () => {
427
430
  tmpDir = await mkTmpDir();
428
431
  const store = createJournalStore(tmpDir);
429
432
  const entry = await store.write({
@@ -431,191 +434,205 @@ describe("journal store", () => {
431
434
  body: "Unrelated topics include coastal erosion, orbital mechanics, ceramic glazing, " +
432
435
  "database indexing, ancient trade routes, orchard irrigation, keyboard switches, " +
433
436
  "bird migration, public transit planning, and archival paper chemistry. These notes " +
434
- "continue across varied subjects so the long body dilutes its mean-pooled embedding. " +
437
+ "continue across varied subjects in this deliberately long journal entry. " +
435
438
  "Further unrelated details cover weather instruments, typography, fermentation, " +
436
439
  "geology, woodworking, astronomy, logistics, and museum conservation. The entry is " +
437
- "deliberately verbose and semantically unrelated to a fresh exact-title query, while " +
440
+ "deliberately verbose and does not repeat the title terms in its body, while " +
438
441
  "its title remains the searchable anchor for this regression case.",
439
442
  });
440
443
 
441
- // The deterministic mock embeddings produce an unrelated query vector here;
442
- // the title-anchor floor guarantees presence regardless of cosine score.
443
444
  const result = await store.search({ text: "Agent-memory release delivery channel" });
444
445
  expect(result.entries.some((candidate) => candidate.id === entry.id)).toBe(true);
445
446
  });
446
447
 
447
- test("search skips stale-dimension embedding and falls back to text match", async () => {
448
+ test("lexical matching finds a rare exact token", async () => {
448
449
  tmpDir = await mkTmpDir();
449
450
  const store = createJournalStore(tmpDir);
451
+ const entry = await store.write({ title: "Unrelated note", body: "The rarelexicaltoken appears here." });
452
+ const query = "rarelexicaltoken";
450
453
 
451
- const entry = await store.write({
452
- title: "Stale dims entry",
453
- body: "unique-stale-dim-content",
454
- });
455
-
456
- // First search: caches the entry with the correct mock embedding.
457
- await store.search({ text: "unique-stale-dim-content" });
458
-
459
- // Simulate a v2 embedding with a WRONG dimension (e.g. from a 768d model).
460
- // Search must not throw: it should skip the embedding and fall back to
461
- // text matching for this entry.
462
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
463
- await fs.writeFile(
464
- embeddingFile,
465
- JSON.stringify({
466
- v: 2,
467
- model: "some-768d-model",
468
- dimension: 768,
469
- vector: Array.from({ length: 768 }, () => 0.1),
470
- }),
471
- "utf-8",
472
- );
473
-
474
- // Query whose embedding would collide with the stale one if cosine were
475
- // attempted (dimension mismatch would throw inside cosineSimilarity).
476
- const result = await store.search({ text: "unique-stale-dim-content" });
477
- const titles = result.entries.map((e) => e.title);
478
- expect(titles).toContain("Stale dims entry");
479
- // No throw above is the assertion: mismatched dimension degrades to text.
454
+ const result = await store.search({ text: query });
455
+ expect(result.total).toBe(1);
456
+ expect(result.entries.map((e) => e.id)).toEqual([entry.id]);
480
457
  });
481
458
 
482
- test("sidecar rewrite after cache warm is picked up by second search", async () => {
459
+ test("OR matching returns unique matches and stable pagination across identical calls", async () => {
483
460
  tmpDir = await mkTmpDir();
484
461
  const store = createJournalStore(tmpDir);
462
+ for (let i = 0; i < 4; i++) {
463
+ await store.write({ title: `Stable ${i}`, body: "sharedtoken searchable" });
464
+ await new Promise((r) => setTimeout(r, 5));
465
+ }
485
466
 
486
- const entry = await store.write({
487
- title: "Sidecar invalidation",
488
- body: "unique-sidecar-invalidation-content",
489
- });
490
-
491
- // First search: caches the entry + correct sidecar fingerprint.
492
- const r1 = await store.search({ text: "unique-sidecar-invalidation-content" });
493
- expect(r1.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
494
-
495
- // Rewrite the sidecar with a wrong-dimension embedding (simulating
496
- // sidecar regeneration without touching the .md).
497
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
498
- await fs.writeFile(
499
- embeddingFile,
500
- JSON.stringify({
501
- v: 2,
502
- model: "some-768d-model",
503
- dimension: 768,
504
- vector: Array.from({ length: 768 }, () => 0.1),
505
- }),
506
- "utf-8",
507
- );
508
-
509
- // Second search must invalidate the cache (sidecar mtime changed) and
510
- // pick up the stale-dimension embedding, falling back to text match.
511
- const r2 = await store.search({ text: "unique-sidecar-invalidation-content" });
512
- expect(r2.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
467
+ const first = await store.search({ text: "sharedtoken", limit: 2 });
468
+ const second = await store.search({ text: "sharedtoken", limit: 2 });
469
+ expect(first.total).toBe(4);
470
+ expect(second.total).toBe(4);
471
+ expect(first.entries.map((e) => e.id)).toEqual(second.entries.map((e) => e.id));
472
+ const next = await store.search({ text: "sharedtoken", limit: 2, offset: 2 });
473
+ expect([...first.entries, ...next.entries].map((e) => e.id)).toHaveLength(4);
474
+ expect(new Set([...first.entries, ...next.entries].map((e) => e.id)).size).toBe(4);
513
475
  });
514
476
 
515
- test("sidecar deleted between searches falls back to text match", async () => {
477
+ test("identical text searches reuse the lexical index snapshot", async () => {
516
478
  tmpDir = await mkTmpDir();
517
479
  const store = createJournalStore(tmpDir);
518
-
519
- const entry = await store.write({
520
- title: "Sidecar deleted entry",
521
- body: "unique-sidecar-deleted-content",
522
- });
523
-
524
- // First search: caches the entry + sidecar fingerprint.
525
- const r1 = await store.search({ text: "unique-sidecar-deleted-content" });
526
- expect(r1.entries.some((e) => e.title === "Sidecar deleted entry")).toBe(true);
527
-
528
- // Delete the sidecar file.
529
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
530
- await fs.unlink(embeddingFile);
531
-
532
- // Second search: cache miss (sidecar gone), falls back to text match
533
- // (score 0.5) rather than using the stale cached embedding.
534
- const r2 = await store.search({ text: "unique-sidecar-deleted-content" });
535
- const found = r2.entries.find((e) => e.title === "Sidecar deleted entry");
536
- expect(found).toBeDefined();
537
- // Score is 0.5 (text fallback), not a cosine score
480
+ await store.write({ title: "Cache token", body: "repeated searchable material" });
481
+ const before = lexicalIndexConstructions;
482
+ await store.search({ text: "cache" });
483
+ expect(lexicalIndexConstructions).toBe(before + 1);
484
+ await store.search({ text: "cache" });
485
+ expect(lexicalIndexConstructions).toBe(before + 1);
538
486
  });
539
487
 
540
- test("search skips corrupt v2 embedding (empty vector) without throwing", async () => {
488
+ test("adding an entry rebuilds the index and makes it searchable", async () => {
541
489
  tmpDir = await mkTmpDir();
542
490
  const store = createJournalStore(tmpDir);
491
+ await store.write({ title: "Before", body: "ordinary content" });
492
+ const before = lexicalIndexConstructions;
493
+ await store.search({ text: "newlyaddedtoken" });
494
+ await store.write({ title: "New entry", body: "newlyaddedtoken appears here" });
495
+ const result = await store.search({ text: "newlyaddedtoken" });
496
+ expect(lexicalIndexConstructions).toBe(before + 2);
497
+ expect(result.entries.map((entry) => entry.title)).toEqual(["New entry"]);
498
+ });
543
499
 
544
- const entry = await store.write({
545
- title: "Corrupt embedding entry",
546
- body: "unique-corrupt-embedding-content",
547
- });
548
-
549
- // Simulate a corrupt-but-well-formed v2 embedding: dimension claims 384
550
- // but the vector is empty. Search must NOT throw — it should skip the
551
- // embedding and fall back to text matching for this entry.
552
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
553
- await fs.writeFile(
554
- embeddingFile,
555
- JSON.stringify({ v: 2, model: "some-model", dimension: 8, vector: [] }),
556
- "utf-8",
557
- );
558
-
559
- const result = await store.search({ text: "unique-corrupt-embedding-content" });
560
- const titles = result.entries.map((e) => e.title);
561
- expect(titles).toContain("Corrupt embedding entry");
562
- // No throw above is the assertion: corrupt v2 embedding degrades to text.
500
+ test("editing entry bytes invalidates the index through the entry stat cache", async () => {
501
+ tmpDir = await mkTmpDir();
502
+ const store = createJournalStore(tmpDir);
503
+ const entry = await store.write({ title: "Editable", body: "old unique phrase" });
504
+ await store.search({ text: "old" });
505
+ const before = lexicalIndexConstructions;
506
+ const original = await fs.readFile(entry.filePath, "utf-8");
507
+ await new Promise((resolve) => setTimeout(resolve, 5));
508
+ await fs.writeFile(entry.filePath, original.replace("old unique phrase", "replacementtoken with more bytes"));
509
+ const oldResult = await store.search({ text: "old" });
510
+ const newResult = await store.search({ text: "replacementtoken" });
511
+ expect(lexicalIndexConstructions).toBe(before + 1);
512
+ expect(oldResult.total).toBe(0);
513
+ expect(newResult.entries.map((candidate) => candidate.id)).toEqual([entry.id]);
514
+ });
515
+
516
+ test("a metadata filter changing membership rebuilds the lexical index", async () => {
517
+ tmpDir = await mkTmpDir();
518
+ const store = createJournalStore(tmpDir);
519
+ await store.write({ title: "Alpha", body: "sharedfiltertoken", project: "/proj/a" });
520
+ await new Promise((resolve) => setTimeout(resolve, 5));
521
+ await store.write({ title: "Beta", body: "sharedfiltertoken", project: "/proj/b" });
522
+ const before = lexicalIndexConstructions;
523
+ const a = await store.search({ text: "sharedfiltertoken", project: "/proj/a" });
524
+ const b = await store.search({ text: "sharedfiltertoken", project: "/proj/b" });
525
+ expect(lexicalIndexConstructions).toBe(before + 2);
526
+ expect(a.entries.map((entry) => entry.title)).toEqual(["Alpha"]);
527
+ expect(b.entries.map((entry) => entry.title)).toEqual(["Beta"]);
528
+ });
529
+
530
+ test("interleaved searches with different filters retain their own index", async () => {
531
+ tmpDir = await mkTmpDir();
532
+ const store = createJournalStore(tmpDir);
533
+ const a = await store.write({ title: "Filtered A", body: "shared interleaved token", project: "/proj/a" });
534
+ await new Promise((resolve) => setTimeout(resolve, 5));
535
+ const b = await store.write({ title: "Filtered B", body: "shared interleaved token", project: "/proj/b" });
536
+ const [resultA, resultB] = await Promise.all([
537
+ store.search({ text: "interleaved", project: "/proj/a" }),
538
+ store.search({ text: "interleaved", project: "/proj/b" }),
539
+ ]);
540
+ expect(resultA.entries.map((entry) => entry.id)).toEqual([a.id]);
541
+ expect(resultB.entries.map((entry) => entry.id)).toEqual([b.id]);
542
+ expect(resultA.total).toBe(1);
543
+ expect(resultB.total).toBe(1);
563
544
  });
564
545
 
565
- test("search skips corrupt v2 embedding (missing vector) without throwing", async () => {
546
+ test("OR rankings and totals match an uncached MiniSearch fixture", async () => {
566
547
  tmpDir = await mkTmpDir();
567
548
  const store = createJournalStore(tmpDir);
549
+ const docs = [
550
+ ["Alpha systems", "blue green systems reliability"],
551
+ ["Green garden", "green plants bloom in spring"],
552
+ ["Blue ocean", "blue water and marine life"],
553
+ ["Reliable storage", "systems store durable records"],
554
+ ["Spring systems", "green systems launch in spring"],
555
+ ["Marine records", "ocean blue records and data"],
556
+ ["Durable garden", "plants grow with reliable soil"],
557
+ ["Data storage", "durable systems for data"],
558
+ ["Blooming ocean", "spring marine bloom"],
559
+ ["Record reliability", "reliable records stay durable"],
560
+ ];
561
+ const entries = [] as Awaited<ReturnType<typeof store.write>>[];
562
+ for (const [title, body] of docs) {
563
+ entries.push(await store.write({ title: title!, body: body! }));
564
+ await new Promise((resolve) => setTimeout(resolve, 2));
565
+ }
566
+ const queries = ["green systems", "blue records", "durable reliable", "spring ocean", "plants data"];
567
+ for (const text of queries) {
568
+ const uncached = new NativeMiniSearch({ fields: ["title", "body"], storeFields: ["id"] });
569
+ uncached.addAll(entries.map((entry) => ({ id: entry.id, title: entry.title, body: entry.body })));
570
+ const expected = uncached.search(text, { combineWith: "OR" })
571
+ .map((match) => ({ entry: entries.find((entry) => entry.id === String(match.id))!, score: match.score }))
572
+ .sort((a, b) => b.score - a.score || b.entry.created.getTime() - a.entry.created.getTime() || (a.entry.id < b.entry.id ? 1 : a.entry.id > b.entry.id ? -1 : 0));
573
+ const actual = await store.search({ text });
574
+ expect({ ids: actual.entries.map((entry) => entry.id), total: actual.total }).toEqual({
575
+ ids: expected.map(({ entry }) => entry.id),
576
+ total: expected.length,
577
+ });
578
+ }
579
+ });
568
580
 
569
- const entry = await store.write({
570
- title: "Missing vector entry",
571
- body: "unique-missing-vector-content",
572
- });
581
+ test("punctuation-only query falls back to case-insensitive title/body substring matches", async () => {
582
+ tmpDir = await mkTmpDir();
583
+ const store = createJournalStore(tmpDir);
584
+ const titleMatch = await store.write({ title: "!!!", body: "older" });
585
+ await new Promise((r) => setTimeout(r, 5));
586
+ const bodyMatch = await store.write({ title: "Other", body: "A marker !!! appears here." });
587
+ await new Promise((r) => setTimeout(r, 5));
588
+ await store.write({ title: "No match", body: "ordinary text" });
589
+ const result = await store.search({ text: "!!!" });
590
+ expect(result.total).toBe(2);
591
+ expect(result.entries.map((entry) => entry.id)).toEqual([bodyMatch.id, titleMatch.id]);
592
+ });
573
593
 
574
- // v2 file that omits the vector field entirely. Search must not throw.
575
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
576
- await fs.writeFile(
577
- embeddingFile,
578
- JSON.stringify({ v: 2, model: "some-model", dimension: 8 }),
579
- "utf-8",
580
- );
594
+ test("text search does not generate embeddings", async () => {
595
+ tmpDir = await mkTmpDir();
596
+ const store = createJournalStore(tmpDir);
597
+ const entry = await store.write({ title: "Keyword note", body: "the lexicalneedle is here" });
598
+ const callsBeforeSearch = embeddingGenerationCalls;
581
599
 
582
- const result = await store.search({ text: "unique-missing-vector-content" });
583
- const titles = result.entries.map((e) => e.title);
584
- expect(titles).toContain("Missing vector entry");
600
+ const result = await store.search({ text: "lexicalneedle" });
601
+ expect(result.entries.map((candidate) => candidate.id)).toEqual([entry.id]);
602
+ expect(embeddingGenerationCalls - callsBeforeSearch).toBe(0);
585
603
  });
586
604
 
587
- test("legacy bare-array embedding is still usable for semantic search", async () => {
605
+ test("text search matches query terms with OR and orders score, creation time, then id descending", async () => {
588
606
  tmpDir = await mkTmpDir();
589
607
  const store = createJournalStore(tmpDir);
608
+ const low = await store.write({ title: "Tie", body: "alpha" });
609
+ await new Promise((resolve) => setTimeout(resolve, 5));
610
+ const highId = await store.write({ title: "Tie", body: "alpha" });
611
+ await new Promise((resolve) => setTimeout(resolve, 5));
612
+ const newest = await store.write({ title: "Tie", body: "beta alpha" });
613
+ const tiedCreated = "2026-01-01T00:00:00.000Z";
614
+ for (const entry of [low, highId]) {
615
+ const original = await fs.readFile(entry.filePath, "utf-8");
616
+ await fs.writeFile(entry.filePath, original.replace(/^created: .+$/m, `created: '${tiedCreated}'`));
617
+ }
590
618
 
591
- const entry = await store.write({
592
- title: "Legacy embedding",
593
- body: "legacy-vector-body",
594
- });
619
+ const result = await store.search({ text: "alpha beta" });
620
+ expect(result.entries.map((entry) => entry.id)).toEqual([newest.id, highId.id, low.id]);
621
+ expect(result.total).toBe(3);
622
+ });
595
623
 
596
- // Overwrite with a legacy v1 bare-array embedding (from all-MiniLM-L6-v2,
597
- // which also produces 8-dim vectors under the test mock). Use the same
598
- // mock formula as generateEmbedding so the cosine is positive and the
599
- // entry is actually matched via semantic search.
600
- const hash = Array.from("legacy-vector-body").reduce(
601
- (acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
602
- 0,
603
- );
604
- const legacyVector = Array.from(
605
- { length: 8 },
606
- (_, i) => Math.sin(hash + i),
607
- );
608
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
609
- await fs.writeFile(
610
- embeddingFile,
611
- JSON.stringify(legacyVector),
612
- "utf-8",
613
- );
624
+ test("search without text remains chronological and does not generate embeddings", async () => {
625
+ tmpDir = await mkTmpDir();
626
+ const store = createJournalStore(tmpDir);
627
+ await store.write({ title: "Older", body: "first" });
628
+ await new Promise((r) => setTimeout(r, 5));
629
+ await store.write({ title: "Newer", body: "second" });
630
+ const callsBeforeSearch = embeddingGenerationCalls;
631
+ const indexesBeforeSearch = lexicalIndexConstructions;
614
632
 
615
- // Legacy array must be treated as { vector, model: "unknown-legacy",
616
- // dimension: length }; with a matching dimension it stays usable.
617
- const result = await store.search({ text: "legacy-vector-body" });
618
- const titles = result.entries.map((e) => e.title);
619
- expect(titles).toContain("Legacy embedding");
633
+ const result = await store.search({});
634
+ expect(result.entries.map((entry) => entry.title)).toEqual(["Newer", "Older"]);
635
+ expect(embeddingGenerationCalls).toBe(callsBeforeSearch);
636
+ expect(lexicalIndexConstructions).toBe(indexesBeforeSearch);
620
637
  });
621
638
  });