@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.
- package/README.md +21 -7
- package/package.json +7 -3
- package/src/doctor.test.ts +183 -0
- package/src/doctor.ts +87 -0
- package/src/journal-doctor.ts +11 -0
- package/src/journal.test.ts +197 -180
- package/src/journal.ts +72 -128
- package/src/plugin-disabled.test.ts +22 -9
- package/src/plugin.test.ts +147 -51
- package/src/plugin.ts +20 -1
- package/src/test-suite.test.ts +15 -0
- package/src/tools.test.ts +2 -2
- package/src/tools.ts +3 -3
package/src/journal.test.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
+
let embeddingGenerationCalls = 0;
|
|
8
|
+
let lexicalIndexConstructions = 0;
|
|
6
9
|
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
421
|
-
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
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("
|
|
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
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
expect(
|
|
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("
|
|
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
|
|
520
|
-
|
|
521
|
-
|
|
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("
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
await
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
);
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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("
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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: "
|
|
583
|
-
|
|
584
|
-
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);
|
|
585
603
|
});
|
|
586
604
|
|
|
587
|
-
test("
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
});
|