@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 +13 -7
- package/package.json +2 -1
- package/src/journal.test.ts +182 -178
- package/src/journal.ts +71 -127
- package/src/tools.test.ts +2 -2
- package/src/tools.ts +3 -3
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
|
|
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
|
|
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@
|
|
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` |
|
|
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
|
-
###
|
|
88
|
+
### Text search
|
|
89
89
|
|
|
90
|
-
|
|
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
|
-
- **
|
|
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": "
|
|
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": {
|
package/src/journal.test.ts
CHANGED
|
@@ -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 (
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
433
|
-
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
464
|
-
|
|
465
|
-
|
|
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("
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
expect(
|
|
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("
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
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("
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
await
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
);
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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("
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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: "
|
|
596
|
-
|
|
597
|
-
expect(
|
|
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("
|
|
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
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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 {
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
403
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
+
ranked = ranked.slice(offset, offset + limit);
|
|
498
442
|
|
|
499
443
|
return {
|
|
500
|
-
entries:
|
|
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("
|
|
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: "
|
|
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: "
|
|
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
|
},
|