@ghilteras/opencode-agent-memory 1.2.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  >
7
7
  > Originally created by Joshua David Thomas and licensed under the MIT license. Attribution preserved below; see License section.
8
8
 
9
- A journal-only memory plugin for [OpenCode](https://opencode.ai): an append-only, tagged journal with local semantic search.
9
+ A journal-only memory plugin for [OpenCode](https://opencode.ai): an append-only, tagged journal with local lexical search.
10
10
 
11
11
  ## Experimental
12
12
 
@@ -17,7 +17,7 @@ Think of it as a searchable sidecar to `AGENTS.md`. OpenCode supports [rules](ht
17
17
  ## Features
18
18
 
19
19
  - **Append-only journal** - Entries survive across sessions and context compaction
20
- - **Local semantic search** - Find entries by meaning, not just keywords
20
+ - **Local lexical search** - Find candidate entries by matching query words in titles and bodies
21
21
  - **Metadata on every entry** - Project, model, provider, agent, session, timestamp, tags
22
22
  - **Guidance in tool descriptions** - Journal usage guidance lives in the descriptions of the journal tools, so an agent sees it only when it is allowed to call them
23
23
  - **No data leaves the machine** - Embeddings run locally
@@ -32,7 +32,7 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
32
32
 
33
33
  ```json
34
34
  {
35
- "plugins": ["@ghilteras/opencode-agent-memory@1.1.0"]
35
+ "plugins": ["@ghilteras/opencode-agent-memory@2.0.0"]
36
36
  }
37
37
  ```
38
38
 
@@ -63,7 +63,7 @@ The agent gets 3 tools:
63
63
  | Tool | Description |
64
64
  |------|-------------|
65
65
  | `journal_write` | Write a new append-only entry (title, body, optional tags) |
66
- | `journal_search` | Search entries semantically, filter by project or tags, with pagination |
66
+ | `journal_search` | Find lexical candidates by matching query words in titles and bodies; filter by project or tags, with pagination |
67
67
  | `journal_read` | Read a specific journal entry by ID |
68
68
 
69
69
  ### Entry format
@@ -85,9 +85,9 @@ tags: [plugin, wiring]
85
85
  Body text...
86
86
  ```
87
87
 
88
- ### Semantic search
88
+ ### Text search
89
89
 
90
- 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. An `.embedding` sidecar is written next to each entry in a versioned format (`{ v: 2, model, dimension, vector }`). The search gate is a **dimension** check: an entry whose recorded `dimension` differs from the plugin's expected dimension is not cosine-compared - it falls back to text matching instead of failing the search. Legacy bare-array embeddings from v0.3.x remain readable.
90
+ Text search runs one MiniSearch BM25+ pass over title and body. Query terms are OR-matched, with native score order and a deterministic tie-break by creation time and ID. This is keyword matching, not a claim of semantic relevance: results are candidates for the agent to inspect. Matching works across languages by matching literal words, not by meaning. When a query has no tokenizable terms (for example, `!!!`), search falls back to case-insensitive exact-substring matching over title and body. The retained index is reused while the ordered candidate-entry ID list is unchanged, avoiding repeated index construction. Embedding sidecars are still written in the versioned `{ v: 2, model, dimension, vector }` format for compatibility, but search does not read them.
91
91
 
92
92
  ### Suggested tags
93
93
 
@@ -129,6 +129,12 @@ Two behaviour changes:
129
129
  - **The journal tools are registered by default.** Previously you had to opt in through `agent-memory.json`; now an absent config means the tools are on, and you turn them off with `"enabled": false`.
130
130
  - **Storage is unchanged.** Existing entries under `~/.config/opencode/journal/` and their `.embedding` sidecars stay readable; nothing is migrated or rewritten.
131
131
 
132
+ ## Upgrading to lexical candidate search
133
+
134
+ - Text search now ranks entries with lexical OR matching: a candidate may match any query term in its title or body.
135
+ - Results are candidates, not relevance claims. `Found N` reports the candidate count before pagination and will usually be large.
136
+ - A punctuation-only query falls back to case-insensitive substring matching over title and body.
137
+
132
138
  ## Upgrading from 0.4.x
133
139
 
134
140
  Memory blocks and their tools (`memory_list`, `memory_set`, `memory_replace`) are **removed**. Blocks are no longer read, written, or injected into the system prompt; the plugin ships only the journal.
@@ -140,7 +146,7 @@ Existing `~/.config/opencode/memory/` and `.opencode/memory/` directories are **
140
146
  - **Requires OpenCode v2.** This release uses the OpenCode v2 plugin API and does not run on v1.
141
147
  - **Restart after config changes.** Plugin config changes require a full OpenCode restart to take effect; editing the config file alone is not sufficient.
142
148
  - **Journal is on by default.** Turn it off with `"enabled": false`; an explicit disable is silent, while a config that exists but is unreadable, malformed, or schema-invalid logs a warning and registers no tools (fail-closed).
143
- - **Local embeddings, no data leaves the machine.** Semantic search uses a locally cached transformers.js model; no external API calls are made for search or embedding.
149
+ - **Embedding sidecars remain compatible.** They are written locally for the existing format contract, diagnostics, and future consumers; text search is lexical and does not read embeddings.
144
150
  - **Known limitation (since 0.5.1): guidance-in-descriptions depends on an unverified premise.** 0.5.1 moved journal guidance from the injected system-prompt note into the three journal tool descriptions, so only agents allowed to call those tools should see it. That relies on opencode omitting a permission-denied tool from the definitions sent to the model. This is **unverified**: a `tool.definition` probe on the isolated instance fires before permission filtering, so it cannot confirm what the model actually receives. If the premise does not hold, an agent denied `journal_*` may still read the description text; `deny` still blocks execution, so there is no capability escalation and no data disclosure — the blast radius is the three tools' description strings.
145
151
 
146
152
  ## Inspiration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghilteras/opencode-agent-memory",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "Journal-only memory plugin for OpenCode: tagged, searchable, append-only journal with semantic search.",
5
5
  "author": "Angelo Pantano <ghilteras@protonmail.com>",
6
6
  "license": "MIT",
@@ -27,6 +27,7 @@
27
27
  "@huggingface/transformers": "^3.8.1",
28
28
  "@opencode/plugin": "^2.0.18",
29
29
  "js-yaml": "^4.1.0",
30
+ "minisearch": "^7.2.0",
30
31
  "zod": "^4.1.13"
31
32
  },
32
33
  "overrides": {
@@ -2,40 +2,31 @@ 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
4
  import * as realEmbeddings from "./embeddings";
5
+ import NativeMiniSearch from "minisearch";
6
+
7
+ let embeddingGenerationCalls = 0;
8
+ let lexicalIndexConstructions = 0;
9
+
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 }));
5
18
 
6
19
  // Mock the embeddings module to avoid downloading a real model in tests.
7
20
  // Spread the real module so every export not overridden here keeps its real
8
21
  // value regardless of test-file discovery order.
9
22
  mock.module("./embeddings", () => ({
10
23
  ...realEmbeddings,
11
- generateEmbedding: async (text: string) => {
12
- // Deterministic fake embedding based on text content
13
- const hash = Array.from(text).reduce(
14
- (acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
15
- 0,
16
- );
17
- return Array.from({ length: 8 }, (_, i) => Math.sin(hash + i));
24
+ generateEmbedding: async () => {
25
+ embeddingGenerationCalls++;
26
+ return Array.from({ length: 8 }, (_, i) => i / 8);
18
27
  },
19
28
  EMBEDDING_DIMENSION: 8,
20
29
  embeddingModelName: () => "test-model",
21
- cosineSimilarity: (a: number[], b: number[]) => {
22
- if (a.length !== b.length)
23
- throw new Error(
24
- `Embedding dimension mismatch: ${a.length} vs ${b.length}`,
25
- );
26
- let dot = 0;
27
- let normA = 0;
28
- let normB = 0;
29
- for (let i = 0; i < a.length; i++) {
30
- const ai = a[i]!;
31
- const bi = b[i]!;
32
- dot += ai * bi;
33
- normA += ai * ai;
34
- normB += bi * bi;
35
- }
36
- const denom = Math.sqrt(normA) * Math.sqrt(normB);
37
- return denom === 0 ? 0 : dot / denom;
38
- },
39
30
  }));
40
31
 
41
32
  async function mkTmpDir(): Promise<string> {
@@ -143,6 +134,8 @@ describe("journal store", () => {
143
134
  let tmpDir: string;
144
135
 
145
136
  afterEach(async () => {
137
+ embeddingGenerationCalls = 0;
138
+ lexicalIndexConstructions = 0;
146
139
  if (tmpDir) {
147
140
  await fs.rm(tmpDir, { recursive: true, force: true });
148
141
  }
@@ -415,7 +408,7 @@ describe("journal store", () => {
415
408
  expect(result.allTags).toEqual(["debugging", "perf", "rust"]);
416
409
  });
417
410
 
418
- test("search by text uses semantic matching", async () => {
411
+ test("search by text uses lexical OR matching", async () => {
419
412
  tmpDir = await mkTmpDir();
420
413
  const store = createJournalStore(tmpDir);
421
414
 
@@ -429,13 +422,11 @@ describe("journal store", () => {
429
422
  body: "Set up pytest with coverage reporting.",
430
423
  });
431
424
 
432
- // With mock embeddings, text search falls through to semantic matching
433
- const result = await store.search({ text: "Rust performance" });
434
- // Should find at least the matching entry
435
- 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"]);
436
427
  });
437
428
 
438
- test("title query surfaces long-body entry despite diluted embedding", async () => {
429
+ test("title query matches terms from the title", async () => {
439
430
  tmpDir = await mkTmpDir();
440
431
  const store = createJournalStore(tmpDir);
441
432
  const entry = await store.write({
@@ -443,192 +434,205 @@ describe("journal store", () => {
443
434
  body: "Unrelated topics include coastal erosion, orbital mechanics, ceramic glazing, " +
444
435
  "database indexing, ancient trade routes, orchard irrigation, keyboard switches, " +
445
436
  "bird migration, public transit planning, and archival paper chemistry. These notes " +
446
- "continue across varied subjects so the long body dilutes its mean-pooled embedding. " +
437
+ "continue across varied subjects in this deliberately long journal entry. " +
447
438
  "Further unrelated details cover weather instruments, typography, fermentation, " +
448
439
  "geology, woodworking, astronomy, logistics, and museum conservation. The entry is " +
449
- "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 " +
450
441
  "its title remains the searchable anchor for this regression case.",
451
442
  });
452
443
 
453
- // The deterministic mock embeddings produce an unrelated query vector here;
454
- // the title-anchor floor guarantees presence regardless of cosine score.
455
444
  const result = await store.search({ text: "Agent-memory release delivery channel" });
456
445
  expect(result.entries.some((candidate) => candidate.id === entry.id)).toBe(true);
457
446
  });
458
447
 
459
- test("search skips stale-dimension embedding and falls back to text match", async () => {
448
+ test("lexical matching finds a rare exact token", async () => {
460
449
  tmpDir = await mkTmpDir();
461
450
  const store = createJournalStore(tmpDir);
451
+ const entry = await store.write({ title: "Unrelated note", body: "The rarelexicaltoken appears here." });
452
+ const query = "rarelexicaltoken";
462
453
 
463
- const entry = await store.write({
464
- title: "Stale dims entry",
465
- body: "unique-stale-dim-content",
466
- });
467
-
468
- // First search: caches the entry with the correct mock embedding.
469
- await store.search({ text: "unique-stale-dim-content" });
470
-
471
- // Simulate a v2 embedding with a WRONG dimension (e.g. from a 768d model).
472
- // Search must not throw: it should skip the embedding and fall back to
473
- // text matching for this entry.
474
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
475
- await fs.writeFile(
476
- embeddingFile,
477
- JSON.stringify({
478
- v: 2,
479
- model: "some-768d-model",
480
- dimension: 768,
481
- vector: Array.from({ length: 768 }, () => 0.1),
482
- }),
483
- "utf-8",
484
- );
485
-
486
- // Query whose embedding would collide with the stale one if cosine were
487
- // attempted (dimension mismatch would throw inside cosineSimilarity).
488
- const result = await store.search({ text: "unique-stale-dim-content" });
489
- const titles = result.entries.map((e) => e.title);
490
- expect(titles).toContain("Stale dims entry");
491
- // 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]);
492
457
  });
493
458
 
494
- 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 () => {
495
460
  tmpDir = await mkTmpDir();
496
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
+ }
497
466
 
498
- const entry = await store.write({
499
- title: "Sidecar invalidation",
500
- body: "unique-sidecar-invalidation-content",
501
- });
502
-
503
- // First search: caches the entry + correct sidecar fingerprint.
504
- const r1 = await store.search({ text: "unique-sidecar-invalidation-content" });
505
- expect(r1.entries.some((e) => e.title === "Sidecar invalidation")).toBe(true);
506
-
507
- // Rewrite the sidecar with a wrong-dimension embedding (simulating
508
- // sidecar regeneration without touching the .md).
509
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
510
- await fs.writeFile(
511
- embeddingFile,
512
- JSON.stringify({
513
- v: 2,
514
- model: "some-768d-model",
515
- dimension: 768,
516
- vector: Array.from({ length: 768 }, () => 0.1),
517
- }),
518
- "utf-8",
519
- );
520
-
521
- // Second search must invalidate the cache (sidecar mtime changed) and
522
- // pick up the stale-dimension embedding, falling back to text match.
523
- const r2 = await store.search({ text: "unique-sidecar-invalidation-content" });
524
- 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);
525
475
  });
526
476
 
527
- test("sidecar deleted between searches falls back to text match", async () => {
477
+ test("identical text searches reuse the lexical index snapshot", async () => {
528
478
  tmpDir = await mkTmpDir();
529
479
  const store = createJournalStore(tmpDir);
530
-
531
- const entry = await store.write({
532
- title: "Sidecar deleted entry",
533
- body: "unique-sidecar-deleted-content",
534
- });
535
-
536
- // Warm the cache chronologically (loads and caches the sidecar
537
- // fingerprint) without depending on a synthetic semantic score.
538
- const r1 = await store.search({});
539
- expect(r1.entries.some((e) => e.title === "Sidecar deleted entry")).toBe(true);
540
-
541
- // Delete the sidecar file.
542
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
543
- await fs.unlink(embeddingFile);
544
-
545
- // Second search: cache miss (sidecar gone), falls back to text match
546
- // (score 0.5) rather than using the stale cached embedding.
547
- const r2 = await store.search({ text: "unique-sidecar-deleted-content" });
548
- const found = r2.entries.find((e) => e.title === "Sidecar deleted entry");
549
- expect(found).toBeDefined();
550
- // 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);
551
486
  });
552
487
 
553
- test("search skips corrupt v2 embedding (empty vector) without throwing", async () => {
488
+ test("adding an entry rebuilds the index and makes it searchable", async () => {
554
489
  tmpDir = await mkTmpDir();
555
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
+ });
556
499
 
557
- const entry = await store.write({
558
- title: "Corrupt embedding entry",
559
- body: "unique-corrupt-embedding-content",
560
- });
561
-
562
- // Simulate a corrupt-but-well-formed v2 embedding: dimension claims 384
563
- // but the vector is empty. Search must NOT throw — it should skip the
564
- // embedding and fall back to text matching for this entry.
565
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
566
- await fs.writeFile(
567
- embeddingFile,
568
- JSON.stringify({ v: 2, model: "some-model", dimension: 8, vector: [] }),
569
- "utf-8",
570
- );
571
-
572
- const result = await store.search({ text: "unique-corrupt-embedding-content" });
573
- const titles = result.entries.map((e) => e.title);
574
- expect(titles).toContain("Corrupt embedding entry");
575
- // 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);
576
544
  });
577
545
 
578
- test("search skips corrupt v2 embedding (missing vector) without throwing", async () => {
546
+ test("OR rankings and totals match an uncached MiniSearch fixture", async () => {
579
547
  tmpDir = await mkTmpDir();
580
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
+ });
581
580
 
582
- const entry = await store.write({
583
- title: "Missing vector entry",
584
- body: "unique-missing-vector-content",
585
- });
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
+ });
586
593
 
587
- // v2 file that omits the vector field entirely. Search must not throw.
588
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
589
- await fs.writeFile(
590
- embeddingFile,
591
- JSON.stringify({ v: 2, model: "some-model", dimension: 8 }),
592
- "utf-8",
593
- );
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;
594
599
 
595
- const result = await store.search({ text: "unique-missing-vector-content" });
596
- const titles = result.entries.map((e) => e.title);
597
- 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);
598
603
  });
599
604
 
600
- 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 () => {
601
606
  tmpDir = await mkTmpDir();
602
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
+ }
603
618
 
604
- const entry = await store.write({
605
- title: "Legacy embedding",
606
- body: "legacy-vector-body",
607
- });
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
+ });
608
623
 
609
- // Overwrite with a legacy v1 bare-array embedding (from all-MiniLM-L6-v2,
610
- // which also produces 8-dim vectors under the test mock). Use the same
611
- // mock formula as generateEmbedding so the cosine is positive and the
612
- // entry is actually matched via semantic search.
613
- const hash = Array.from("legacy-vector-body").reduce(
614
- (acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0,
615
- 0,
616
- );
617
- const legacyVector = Array.from(
618
- { length: 8 },
619
- (_, i) => Math.sin(hash + i),
620
- );
621
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
622
- await fs.writeFile(
623
- embeddingFile,
624
- JSON.stringify(legacyVector),
625
- "utf-8",
626
- );
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;
627
632
 
628
- // Legacy array must be treated as { vector, model: "unknown-legacy",
629
- // dimension: length }; with a matching dimension it stays usable.
630
- const result = await store.search({ text: "legacy-vector-body" });
631
- const titles = result.entries.map((e) => e.title);
632
- 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);
633
637
  });
634
638
  });
package/src/journal.ts CHANGED
@@ -3,9 +3,10 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
 
5
5
  import * as yaml from "js-yaml";
6
+ import MiniSearch from "minisearch";
6
7
  import { z } from "zod";
7
8
 
8
- import { cosineSimilarity, EMBEDDING_DIMENSION, embeddingModelName, generateEmbedding } from "./embeddings";
9
+ import { EMBEDDING_DIMENSION, embeddingModelName, generateEmbedding } from "./embeddings";
9
10
  import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
10
11
 
11
12
  const TagSchema = z.looseObject({
@@ -139,11 +140,20 @@ function embeddingPath(entryPath: string): string {
139
140
  return entryPath.replace(/\.md$/, ".embedding");
140
141
  }
141
142
 
142
- function titleMatches(queryText: string, title: string): boolean {
143
- const q = queryText.trim().toLowerCase();
144
- if (!q) return false;
145
- const t = title.trim().toLowerCase();
146
- return t.includes(q) || q.includes(t);
143
+ function hasLexicalTerms(text: string): boolean {
144
+ return /[\p{L}\p{N}]+/u.test(text);
145
+ }
146
+
147
+ function substringMatches(
148
+ entries: CachedEntry[],
149
+ query: string,
150
+ ): { entry: JournalEntry; score: number }[] {
151
+ const needle = query.toLowerCase();
152
+ return entries.flatMap(({ entry }) =>
153
+ `${entry.title}\n${entry.body}`.toLowerCase().includes(needle)
154
+ ? [{ entry, score: 0 }]
155
+ : [],
156
+ );
147
157
  }
148
158
 
149
159
  async function readEntryFile(filePath: string): Promise<JournalEntry> {
@@ -178,55 +188,12 @@ async function readEntryFile(filePath: string): Promise<JournalEntry> {
178
188
  };
179
189
  }
180
190
 
181
- type ParsedEmbedding =
182
- | { vector: number[]; model: string; dimension: number }
183
- | undefined;
184
-
185
191
  type CachedEntry = {
186
192
  entryMtimeMs: number;
187
193
  entrySize: number;
188
- embMtimeMs: number;
189
- embSize: number;
190
194
  entry: JournalEntry;
191
- embedding: ParsedEmbedding;
192
195
  };
193
196
 
194
- async function loadEmbedding(entryPath: string): Promise<ParsedEmbedding> {
195
- const ePath = embeddingPath(entryPath);
196
- try {
197
- const raw = await fs.readFile(ePath, "utf-8");
198
- const parsed = JSON.parse(raw);
199
- if (Array.isArray(parsed)) {
200
- // Legacy v1 (bare array from all-MiniLM-L6-v2)
201
- return {
202
- vector: parsed as number[],
203
- model: "unknown-legacy",
204
- dimension: parsed.length,
205
- };
206
- }
207
- if (parsed && typeof parsed === "object" && parsed.v === 2) {
208
- const vector = Array.isArray(parsed.vector)
209
- ? parsed.vector
210
- : undefined;
211
- if (
212
- vector &&
213
- vector.length === (parsed.dimension as number) &&
214
- vector.length > 0
215
- ) {
216
- return {
217
- vector,
218
- model: parsed.model as string,
219
- dimension: parsed.dimension as number,
220
- };
221
- }
222
- return undefined;
223
- }
224
- return undefined;
225
- } catch {
226
- return undefined;
227
- }
228
- }
229
-
230
197
  const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
231
198
 
232
199
  function validateId(id: string): string {
@@ -270,6 +237,7 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
270
237
  // different (including nested or ephemeral) journal directories never
271
238
  // interfere and no foreign keys can accumulate.
272
239
  const entryCache = new Map<string, CachedEntry>();
240
+ let lexicalIndexSnapshot: { ids: string[]; index: MiniSearch } | undefined;
273
241
 
274
242
  return {
275
243
  async write(entry) {
@@ -294,7 +262,7 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
294
262
  const content = buildFrontmatterDocument(frontmatter, entry.body);
295
263
  await atomicWriteFile(filePath, content);
296
264
 
297
- // Generate and save embedding for semantic search
265
+ // Keep the sidecar for compatibility with doctor, the format contract, and future consumers; search does not read it.
298
266
  const searchableText = `${entry.title}\n${entry.body}`;
299
267
  try {
300
268
  const embedding = await generateEmbedding(searchableText, cacheDir);
@@ -345,8 +313,6 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
345
313
  const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
346
314
  const offset = Math.max(query.offset ?? 0, 0);
347
315
 
348
- let entries: { entry: JournalEntry; score: number }[] = [];
349
-
350
316
  // Read all entry files
351
317
  let files: string[];
352
318
  try {
@@ -368,48 +334,28 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
368
334
  if (!alive.has(key)) entryCache.delete(key);
369
335
  }
370
336
 
371
- // If a text query is provided, try semantic search first
372
- let queryEmbedding: number[] | undefined;
373
- if (query.text) {
374
- try {
375
- queryEmbedding = await generateEmbedding(query.text, cacheDir);
376
- } catch {
377
- // Fall back to text search if embedding fails
378
- }
379
- }
380
-
381
337
  // Collect all tags across every entry (before filtering)
382
338
  const tagSet = new Set<string>();
339
+ let entryCacheChanged = false;
383
340
 
384
341
  const indexed = await Promise.all(files.map(async (file) => {
385
342
  const filePath = path.join(journalDir, file);
386
343
  try {
387
- const ePath = embeddingPath(filePath);
388
- const [entryStat, embStat] = await Promise.all([
389
- fs.stat(filePath),
390
- fs.stat(ePath).catch(() => ({ mtimeMs: -1, size: -1 } as const)),
391
- ]);
344
+ const entryStat = await fs.stat(filePath);
392
345
  const cached = entryCache.get(filePath);
393
346
  if (
394
347
  cached &&
395
348
  cached.entryMtimeMs === entryStat.mtimeMs &&
396
- cached.entrySize === entryStat.size &&
397
- cached.embMtimeMs === embStat.mtimeMs &&
398
- cached.embSize === embStat.size
349
+ cached.entrySize === entryStat.size
399
350
  ) {
400
351
  return cached;
401
352
  }
402
- const [entry, embedding] = await Promise.all([
403
- readEntryFile(filePath),
404
- loadEmbedding(filePath),
405
- ]);
353
+ const entry = await readEntryFile(filePath);
354
+ entryCacheChanged = true;
406
355
  const value: CachedEntry = {
407
356
  entryMtimeMs: entryStat.mtimeMs,
408
357
  entrySize: entryStat.size,
409
- embMtimeMs: embStat.mtimeMs,
410
- embSize: embStat.size,
411
358
  entry,
412
- embedding,
413
359
  };
414
360
  entryCache.set(filePath, value);
415
361
  return value;
@@ -418,6 +364,7 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
418
364
  }
419
365
  }));
420
366
 
367
+ const filtered: CachedEntry[] = [];
421
368
  for (const cached of indexed) {
422
369
  if (!cached) continue;
423
370
  const { entry } = cached;
@@ -439,65 +386,62 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
439
386
  if (!allTagsMatch) continue;
440
387
  }
441
388
 
442
- // Score the entry
443
- let score = 0;
444
-
445
- if (query.text) {
446
- if (queryEmbedding) {
447
- // Semantic search
448
- const storedEmbedding = cached.embedding;
449
- if (storedEmbedding) {
450
- // Hard gate: dimension must match, or the embedding is stale
451
- // (different model) and cannot be compared with cosine.
452
- // A mismatched/stale embedding degrades gracefully to text match.
453
- if (storedEmbedding.dimension !== EMBEDDING_DIMENSION) {
454
- const haystack =
455
- `${entry.title}\n${entry.body}`.toLowerCase();
456
- score = haystack.includes(query.text.toLowerCase()) ? 0.5 : 0;
457
- } else {
458
- score = cosineSimilarity(
459
- queryEmbedding,
460
- storedEmbedding.vector,
461
- );
462
- }
463
- } else {
464
- // No embedding stored; fall back to text match
465
- const haystack =
466
- `${entry.title}\n${entry.body}`.toLowerCase();
467
- score = haystack.includes(query.text.toLowerCase()) ? 0.5 : 0;
468
- }
469
- } else {
470
- // Text search fallback
471
- const haystack = `${entry.title}\n${entry.body}`.toLowerCase();
472
- score = haystack.includes(query.text.toLowerCase()) ? 1 : 0;
473
- }
474
-
475
- // Title-anchor floor: a query that matches the entry title (either
476
- // direction, case-insensitive) must not be diluted below the top
477
- // ranks by long-body mean pooling (see 20260905-171239-533).
478
- if (titleMatches(query.text, entry.title)) {
479
- score = Math.max(score, 0.75);
480
- }
389
+ filtered.push(cached);
390
+ }
481
391
 
482
- if (score <= 0) continue;
392
+ let ranked: { entry: JournalEntry; score: number }[];
393
+ if (!query.text) {
394
+ ranked = filtered.map(({ entry }) => ({
395
+ entry,
396
+ score: entry.created.getTime(),
397
+ }));
398
+ ranked.sort((a, b) => b.score - a.score || (a.entry.id < b.entry.id ? 1 : a.entry.id > b.entry.id ? -1 : 0));
399
+ } else {
400
+ const ids = filtered.map(({ entry }) => entry.id);
401
+ const sameEntries = !entryCacheChanged && lexicalIndexSnapshot !== undefined &&
402
+ lexicalIndexSnapshot.ids.length === ids.length &&
403
+ ids.every((id, index) => lexicalIndexSnapshot!.ids[index] === id);
404
+ let lexicalIndex: MiniSearch;
405
+ if (sameEntries) {
406
+ lexicalIndex = lexicalIndexSnapshot!.index;
483
407
  } else {
484
- // No text query - chronological order (score by recency)
485
- score = entry.created.getTime();
408
+ const builtIndex = new MiniSearch({
409
+ fields: ["title", "body"],
410
+ storeFields: ["id"],
411
+ });
412
+ builtIndex.addAll(filtered.map(({ entry }) => ({
413
+ id: entry.id,
414
+ title: entry.title,
415
+ body: entry.body,
416
+ })));
417
+ lexicalIndexSnapshot = { ids, index: builtIndex };
418
+ lexicalIndex = builtIndex;
486
419
  }
487
-
488
- entries.push({ entry, score });
420
+ const byId = new Map(filtered.map(({ entry }) => [entry.id, entry]));
421
+ const lexical = lexicalIndex.search(query.text, { combineWith: "OR" });
422
+ if (hasLexicalTerms(query.text)) {
423
+ ranked = lexical.flatMap((match) => {
424
+ const entry = byId.get(String(match.id));
425
+ return entry ? [{ entry, score: match.score }] : [];
426
+ });
427
+ } else {
428
+ ranked = substringMatches(filtered, query.text);
429
+ }
430
+ ranked.sort((a, b) =>
431
+ b.score - a.score ||
432
+ b.entry.created.getTime() - a.entry.created.getTime() ||
433
+ (a.entry.id < b.entry.id ? 1 : a.entry.id > b.entry.id ? -1 : 0),
434
+ );
489
435
  }
490
436
 
491
- const total = entries.length;
492
-
493
- // Sort by score descending
494
- entries.sort((a, b) => b.score - a.score);
437
+ // Count lexical candidates after metadata filtering, before paging.
438
+ const total = ranked.length;
495
439
 
496
440
  // Apply offset + limit
497
- entries = entries.slice(offset, offset + limit);
441
+ ranked = ranked.slice(offset, offset + limit);
498
442
 
499
443
  return {
500
- entries: entries.map((e) => e.entry),
444
+ entries: ranked.map((e) => e.entry),
501
445
  total,
502
446
  allTags: Array.from(tagSet).sort(),
503
447
  };
package/src/tools.test.ts CHANGED
@@ -31,7 +31,7 @@ describe("v2 journal tools", () => {
31
31
  expect([JournalWrite(baseStore, context).name, JournalRead(baseStore).name, JournalSearch(baseStore).name]).toEqual(["journal_write", "journal_read", "journal_search"]);
32
32
  expect(JournalWrite(baseStore, context).description).toContain("append-only");
33
33
  expect(JournalRead(baseStore).description).toContain("by its ID");
34
- expect(JournalSearch(baseStore).description).toContain("semantic similarity");
34
+ expect(JournalSearch(baseStore).description).toContain("lexical OR matching");
35
35
  });
36
36
 
37
37
  test("journal_write description preserves its behavioral guidance", () => {
@@ -95,7 +95,7 @@ describe("v2 journal tools", () => {
95
95
  }
96
96
  expect(writeResult.content).toContain("Journal entry created:");
97
97
  expect(readResult.content).toContain("Body");
98
- expect(searchResult.content).toContain("Found 1 entries");
98
+ expect(searchResult.content).toContain("Found 1 candidate entries");
99
99
  });
100
100
 
101
101
  test("retains bounded tag inventory output formatting", async () => {
package/src/tools.ts CHANGED
@@ -76,11 +76,11 @@ export function JournalRead(store: JournalStore): ToolDefinition {
76
76
  export function JournalSearch(store: JournalStore): ToolDefinition {
77
77
  return {
78
78
  name: "journal_search",
79
- description: "Search journal entries using semantic similarity. Returns matching entries sorted by relevance. All filters are optional and combined with AND logic. Use with no arguments to list recent entries. Use offset to paginate. Before starting complex tasks, search the journal for relevant past context. The journal is global across all projects but each entry records which project it was written from.",
79
+ description: "Find candidate journal entries by lexical OR matching query words in titles and bodies. Candidates are not relevance claims. All filters are optional and combined with AND logic. Use with no arguments to list recent entries. Use offset to paginate. Before starting complex tasks, search the journal for relevant past context. The journal is global across all projects but each entry records which project it was written from.",
80
80
  input: {
81
81
  type: "object",
82
82
  properties: {
83
- text: { type: "string", description: "Semantic search text" },
83
+ text: { type: "string", description: "Query words for lexical candidate matching in titles and bodies" },
84
84
  project: { type: "string", description: "Filter by project directory" },
85
85
  tags: { type: "string", description: "Comma-separated tags; all must match" },
86
86
  limit: { type: "integer", minimum: 1, description: "Maximum entries to return" },
@@ -99,7 +99,7 @@ export function JournalSearch(store: JournalStore): ToolDefinition {
99
99
  const tagsLine = formatTagsInUse(result.allTags);
100
100
  if (!result.entries.length) return { content: `No journal entries found.${tagsLine}` };
101
101
  const offset = (input.offset as number | undefined) ?? 0;
102
- const header = `Found ${result.total} entries (showing ${offset + 1}–${offset + result.entries.length}):`;
102
+ const header = `Found ${result.total} candidate entries (showing ${offset + 1}–${offset + result.entries.length}):`;
103
103
  const lines = result.entries.map((entry) => `${entry.id}\n ${entry.title}${entry.tags.length ? ` [${entry.tags.join(", ")}]` : ""}\n ${entry.created.toISOString()}`);
104
104
  return { content: `${header}${tagsLine}\n\n${lines.join("\n\n")}` };
105
105
  },