@ghilteras/opencode-agent-memory 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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@2.0.0"]
35
+ "plugins": ["@ghilteras/opencode-agent-memory@2.0.1"]
36
36
  }
37
37
  ```
38
38
 
@@ -74,7 +74,7 @@ Entries are markdown files with YAML frontmatter in `~/.config/opencode/journal/
74
74
  ---
75
75
  title: "Reasoning-pruner wiring"
76
76
  created: 2026-09-21T18:16:11.300Z
77
- project: /home/angelo/homelab-config
77
+ project: /path/to/project
78
78
  model: deepseek-v4.1-flash
79
79
  provider: opencode-go
80
80
  agent: executor
@@ -113,7 +113,7 @@ By default the transformers.js model cache lives in the default location (`~/.ca
113
113
 
114
114
  ```json
115
115
  {
116
- "cacheDir": "/home/angelo/.cache/opencode/memory-model",
116
+ "cacheDir": "~/.cache/opencode/memory-model",
117
117
  "journal": {
118
118
  "enabled": true
119
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghilteras/opencode-agent-memory",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
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",
@@ -13,9 +13,7 @@
13
13
  },
14
14
  "main": "src/plugin.ts",
15
15
  "type": "module",
16
- "files": [
17
- "src"
18
- ],
16
+ "files": ["src", "!src/**/*.test.ts", "!src/test-suite.test.ts"],
19
17
  "scripts": {
20
18
  "test": "bun run test:core && bun run test:plugin",
21
19
  "test:core": "bun test src/test-suite.test.ts src/doctor.test.ts src/embeddings.test.ts src/tools.test.ts src/journal.test.ts",
@@ -1,183 +0,0 @@
1
- import { afterEach, expect, test } from "bun:test";
2
- import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
- import { diagnoseJournal } from "./doctor";
6
-
7
- const roots: string[] = [];
8
- async function fixture() {
9
- const root = await fs.mkdtemp(path.join(os.tmpdir(), "journal-doctor-"));
10
- roots.push(root);
11
- await fs.mkdir(path.join(root, "journal"));
12
- return path.join(root, "journal");
13
- }
14
- async function entry(dir: string, name: string, yamlText?: string, sidecar?: string) {
15
- await fs.writeFile(path.join(dir, `${name}.md`), yamlText === undefined ? "plain text" : `---\n${yamlText}\n---\nbody\n`);
16
- if (sidecar !== undefined) await fs.writeFile(path.join(dir, `${name}.embedding`), sidecar);
17
- }
18
- afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
19
-
20
- test("classifies frontmatter, created values, sidecars and multiple findings deterministically", async () => {
21
- const dir = await fixture();
22
- const valid = 'title: Good\ncreated: "2025-01-01T00:00:00Z"';
23
- await entry(dir, "00-valid", valid, JSON.stringify({ v: 2, model: "wrong-model", dimension: 2, vector: [0.1, 0.2] }));
24
- await entry(dir, "01-missing-title", "created: \"2025-01-01\"");
25
- await entry(dir, "02-empty-title", 'title: ""');
26
- await entry(dir, "03-wrong-title", "title: 42");
27
- await entry(dir, "04-missing-created", "title: Present", "[1,2]");
28
- await entry(dir, "05-date-only", "title: Present\ndate: \"2025-01-01\"");
29
- await entry(dir, "06-invalid-created", 'title: Present\ncreated: "not a date"');
30
- await entry(dir, "07-wrong-created", "title: Present\ncreated: 42");
31
- await entry(dir, "08-yaml-timestamp", "title: Present\ncreated: 2025-01-01T00:00:00Z");
32
- await entry(dir, "09-bad-yaml", "title: [broken");
33
- await entry(dir, "10-no-frontmatter");
34
- await entry(dir, "11-malformed-json", valid, "{");
35
- await entry(dir, "12-vector-type", valid, '{"v":2,"model":"m","dimension":1,"vector":["x"]}');
36
- await entry(dir, "13-empty-vector", valid, '{"v":2,"model":"m","dimension":0,"vector":[]}');
37
- await entry(dir, "14-vector-dimension", valid, '{"v":2,"model":"m","dimension":3,"vector":[1,2]}');
38
- await entry(dir, "15-legacy", valid, "[1,2]");
39
- await entry(dir, "16-legacy-invalid", valid, '[1,"x"]');
40
- await entry(dir, "17-missing-sidecar", valid);
41
-
42
- const inventory = async () => Promise.all((await fs.readdir(dir)).sort().map(async (f) => [f, await fs.readFile(path.join(dir, f)), (await fs.stat(path.join(dir, f))).mtimeMs] as const));
43
- const before = await inventory();
44
- const report = await diagnoseJournal(path.dirname(dir));
45
- expect(await diagnoseJournal(path.dirname(dir))).toEqual(report);
46
- expect(report.counts.files).toBe(18);
47
- const codes = (name: string) => report.findings.filter((f) => f.path.startsWith(name)).map((f) => f.code);
48
- expect(codes("01-missing-title")).toContain("frontmatter_title_invalid");
49
- expect(codes("02-empty-title")).toContain("frontmatter_title_invalid");
50
- expect(codes("03-wrong-title")).toContain("frontmatter_title_invalid");
51
- expect(codes("04-missing-created")).toContain("created_missing");
52
- expect(codes("05-date-only")).toContain("created_missing");
53
- expect(codes("06-invalid-created")).toContain("created_invalid");
54
- expect(codes("07-wrong-created")).toContain("frontmatter_created_invalid");
55
- expect(codes("08-yaml-timestamp")).toContain("frontmatter_created_invalid");
56
- expect(codes("09-bad-yaml")).toContain("yaml_malformed");
57
- expect(codes("10-no-frontmatter")).toContain("frontmatter_missing_or_malformed");
58
- expect(codes("11-malformed-json")).toContain("embedding_json_malformed");
59
- for (const name of ["12-vector-type", "13-empty-vector", "14-vector-dimension", "16-legacy-invalid"]) expect(codes(name)).toContain("embedding_invalid");
60
- expect(codes("00-valid")).toContain("embedding_incompatible");
61
- expect(codes("15-legacy")).toContain("embedding_model_unknown");
62
- expect(codes("17-missing-sidecar")).toContain("embedding_missing");
63
- expect(codes("04-missing-created")).toEqual(expect.arrayContaining(["created_missing", "embedding_model_unknown"]));
64
- expect(report.findings).toEqual([...report.findings].sort((a, b) => a.path.localeCompare(b.path) || a.code.localeCompare(b.code) || a.severity.localeCompare(b.severity)));
65
- expect(report.findings.some((f) => /broken|Good|Present|not a date/.test(JSON.stringify(f)))).toBe(false);
66
- expect(await inventory()).toEqual(before);
67
- });
68
-
69
- test("empty, missing, and injected unreadable roots/files have explicit incomplete semantics", async () => {
70
- const missing = await diagnoseJournal("/path/that/does/not/exist");
71
- expect(missing.complete).toBe(false);
72
- expect(missing.findings[0]?.code).toBe("root_unreadable");
73
- const dir = await fixture();
74
- expect((await diagnoseJournal(path.dirname(dir))).complete).toBe(true);
75
- await entry(dir, "a", "title: A"); await entry(dir, "b", "title: B");
76
- const failingRead = { ...fs, readFile: async (p: Parameters<typeof fs.readFile>[0], ...args: any[]) => {
77
- if (String(p).endsWith("a.md")) throw new Error("injected");
78
- return fs.readFile(p, ...(args as [any]));
79
- } } as unknown as typeof fs;
80
- const report = await diagnoseJournal(path.dirname(dir), failingRead);
81
- expect(report.complete).toBe(false);
82
- expect(report.findings.some((f) => f.path === "a.md" && f.code === "file_unreadable")).toBe(true);
83
- expect(report.findings.some((f) => f.path === "b.md" && f.code === "created_missing")).toBe(true);
84
- });
85
-
86
- test("CLI emits exact bytes and exit codes for clean, warning, error, incomplete and bad invocation", async () => {
87
- const dir = await fixture();
88
- const cli = path.resolve(import.meta.dir, "journal-doctor.ts");
89
- const invoke = (...args: string[]) => Bun.spawnSync([process.execPath, "run", cli, ...args], { stdout: "pipe", stderr: "pipe" });
90
- const assertCli = (result: ReturnType<typeof invoke>, stdout: string, stderr: string, exitCode: number) =>
91
- expect({ stdout: result.stdout.toString(), stderr: result.stderr.toString(), exitCode: result.exitCode }).toEqual({ stdout, stderr, exitCode });
92
- assertCli(invoke(path.dirname(dir)), '{"complete":true,"counts":{"files":0,"errors":0,"warnings":0},"findings":[]}\n', "", 0);
93
-
94
- await entry(dir, "warning", 'title: Warn\ncreated: "2025-01-01"');
95
- assertCli(invoke(path.dirname(dir)), '{"complete":true,"counts":{"files":1,"errors":0,"warnings":1},"findings":[{"path":"warning.embedding","code":"embedding_missing","severity":"warning"}]}\n', "", 0);
96
-
97
- await entry(dir, "invalid", "title: 12");
98
- assertCli(invoke(path.dirname(dir)), '{"complete":true,"counts":{"files":2,"errors":1,"warnings":2},"findings":[{"path":"invalid.embedding","code":"embedding_missing","severity":"warning"},{"path":"invalid.md","code":"frontmatter_title_invalid","severity":"error"},{"path":"warning.embedding","code":"embedding_missing","severity":"warning"}]}\n', "", 1);
99
-
100
- assertCli(invoke("/path/that/does/not/exist"), '{"complete":false,"counts":{"files":0,"errors":1,"warnings":0},"findings":[{"path":".","code":"root_unreadable","severity":"error"}]}\n', "", 2);
101
- assertCli(invoke(path.dirname(dir), "extra"), "", "usage: bun run journal:doctor [config-dir]\n", 2);
102
- });
103
-
104
- test("inventories orphan and associated sidecars independently and emits stable full report", async () => {
105
- const dir = await fixture();
106
- const valid = 'title: Good\ncreated: "2025-01-01T00:00:00Z"';
107
- await entry(dir, "A", valid);
108
- await entry(dir, "a", valid, "{");
109
- await fs.writeFile(path.join(dir, "_.embedding"), "{");
110
- const expected = { complete: true, counts: { files: 2, errors: 2, warnings: 2 }, findings: [
111
- { path: "A.embedding", code: "embedding_missing", severity: "warning" },
112
- { path: "_.embedding", code: "embedding_json_malformed", severity: "error" },
113
- { path: "_.embedding", code: "embedding_orphan", severity: "warning" },
114
- { path: "a.embedding", code: "embedding_json_malformed", severity: "error" },
115
- ] };
116
- const report = await diagnoseJournal(path.dirname(dir));
117
- expect(JSON.stringify(report)).toBe(JSON.stringify(expected));
118
- const cli = path.resolve(import.meta.dir, "journal-doctor.ts");
119
- const result = Bun.spawnSync([process.execPath, "run", cli, path.dirname(dir)], { stdout: "pipe", stderr: "pipe" });
120
- expect({ stdout: result.stdout.toString(), stderr: result.stderr.toString(), exitCode: result.exitCode }).toEqual({ stdout: `${JSON.stringify(expected)}\n`, stderr: "", exitCode: 1 });
121
- });
122
-
123
- test("sidecar validation survives markdown read failure; listed sidecar disappearance is incomplete", async () => {
124
- const dir = await fixture();
125
- await entry(dir, "a", 'title: Good\ncreated: "2025-01-01T00:00:00Z"', "{");
126
- const failingRead = { ...fs, readFile: async (p: Parameters<typeof fs.readFile>[0], ...args: any[]) => {
127
- if (String(p).endsWith("a.md")) throw Object.assign(new Error("denied"), { code: "EACCES" });
128
- return fs.readFile(p, ...(args as [any]));
129
- } } as unknown as typeof fs;
130
- const failed = await diagnoseJournal(path.dirname(dir), failingRead);
131
- expect(failed.complete).toBe(false);
132
- expect(failed.findings.map((f) => f.code)).toEqual(expect.arrayContaining(["file_unreadable", "embedding_json_malformed"]));
133
-
134
- let removed = false;
135
- const disappearing = { ...fs, readFile: async (p: Parameters<typeof fs.readFile>[0], ...args: any[]) => {
136
- if (String(p).endsWith("a.embedding") && !removed) { removed = true; throw Object.assign(new Error("gone"), { code: "ENOENT" }); }
137
- return fs.readFile(p, ...(args as [any]));
138
- } } as unknown as typeof fs;
139
- const gone = await diagnoseJournal(path.dirname(dir), disappearing);
140
- expect(gone.complete).toBe(false);
141
- expect(gone.findings.some((f) => f.path === "a.embedding" && f.code === "embedding_unreadable")).toBe(true);
142
- });
143
-
144
- test("diagnosis never initializes model pipeline or fetches, while warmup activates trap in child", async () => {
145
- const dir = await fixture();
146
- await entry(dir, "a", 'title: Good\ncreated: "2025-01-01T00:00:00Z"');
147
- const childCode = `
148
- import { mock } from "bun:test";
149
- let pipelines = 0, fetches = 0;
150
- const originalFetch = globalThis.fetch;
151
- globalThis.fetch = async (...args) => { fetches++; throw new Error("FETCH_SENTINEL"); };
152
- mock.module("@huggingface/transformers", () => ({ pipeline: async () => { pipelines++; throw new Error("PIPELINE_SENTINEL"); } }));
153
- const { diagnoseJournal } = await import(${JSON.stringify(path.resolve(import.meta.dir, "doctor.ts"))});
154
- const { warmupEmbedder } = await import(${JSON.stringify(path.resolve(import.meta.dir, "embeddings.ts"))});
155
- await diagnoseJournal(${JSON.stringify(path.dirname(dir))});
156
- if (pipelines !== 0 || fetches !== 0) throw new Error("diagnosis activated model/fetch trap");
157
- try { await warmupEmbedder(); throw new Error("pipeline sentinel was not caught"); } catch (error) { if (!String(error).includes("PIPELINE_SENTINEL")) throw error; }
158
- if (pipelines !== 1 || fetches !== 0) throw new Error("pipeline trap count mismatch");
159
- globalThis.fetch = originalFetch;
160
- console.log(JSON.stringify({ pipelines, fetches }));
161
- `;
162
- const result = Bun.spawnSync([process.execPath, "-e", childCode], { stdout: "pipe", stderr: "pipe" });
163
- expect({ stdout: result.stdout.toString(), stderr: result.stderr.toString(), exitCode: result.exitCode }).toEqual({ stdout: '{"pipelines":1,"fetches":0}\n', stderr: "", exitCode: 0 });
164
- });
165
-
166
- test("only flat regular inventory files are read and each sidecar is read once", async () => {
167
- const dir = await fixture();
168
- await entry(dir, "a", 'title: Good\ncreated: "2025-01-01T00:00:00Z"', "[1]");
169
- await fs.mkdir(path.join(dir, "nested"));
170
- await fs.writeFile(path.join(dir, "nested", "hidden.md"), "bad");
171
- await fs.writeFile(path.join(dir, "nested", "hidden.embedding"), "{");
172
- await fs.writeFile(path.join(dir, "ignored.txt"), "bad");
173
- await fs.symlink(path.join(dir, "a.md"), path.join(dir, "linked.md"));
174
- const reads = new Map<string, number>();
175
- const counting = { ...fs, readFile: async (p: Parameters<typeof fs.readFile>[0], ...args: any[]) => {
176
- const name = path.basename(String(p)); reads.set(name, (reads.get(name) ?? 0) + 1);
177
- return fs.readFile(p, ...(args as [any]));
178
- } } as unknown as typeof fs;
179
- const report = await diagnoseJournal(path.dirname(dir), counting);
180
- expect(report.counts.files).toBe(1);
181
- expect([...reads.entries()].sort()).toEqual([["a.embedding", 1], ["a.md", 1]]);
182
- expect(report.findings.map((f) => f.path)).toEqual(["a.embedding", "a.embedding"]);
183
- });
@@ -1,47 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- import {
4
- EMBEDDING_DIMENSION,
5
- cosineSimilarity,
6
- embeddingModelName,
7
- getEmbeddingDimension,
8
- } from "./embeddings";
9
-
10
- describe("embeddings", () => {
11
- test("getEmbeddingDimension returns 384", () => {
12
- expect(getEmbeddingDimension()).toBe(384);
13
- expect(EMBEDDING_DIMENSION).toBe(384);
14
- });
15
-
16
- test("embeddingModelName returns the multilingual model", () => {
17
- expect(embeddingModelName()).toBe(
18
- "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
19
- );
20
- });
21
-
22
- test("cosineSimilarity throws on dimension mismatch", () => {
23
- expect(() => cosineSimilarity([1, 2, 3], [1, 2, 3, 4])).toThrow(
24
- /dimension mismatch/i,
25
- );
26
- });
27
-
28
- test("versioned embedding round-trip serializes and parses back", () => {
29
- const vector = Array.from(
30
- { length: EMBEDDING_DIMENSION },
31
- (_, i) => Math.sin(i),
32
- );
33
- const serialized = JSON.stringify({
34
- v: 2,
35
- model: embeddingModelName(),
36
- dimension: EMBEDDING_DIMENSION,
37
- vector,
38
- });
39
-
40
- const parsed = JSON.parse(serialized);
41
- expect(parsed.v).toBe(2);
42
- expect(parsed.model).toBe(embeddingModelName());
43
- expect(parsed.dimension).toBe(EMBEDDING_DIMENSION);
44
- expect(parsed.vector).toEqual(vector);
45
- expect(parsed.vector.length).toBe(EMBEDDING_DIMENSION);
46
- });
47
- });
@@ -1,638 +0,0 @@
1
- import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
2
- import * as fs from "node:fs/promises";
3
- import * as path from "node:path";
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 }));
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.
22
- mock.module("./embeddings", () => ({
23
- ...realEmbeddings,
24
- generateEmbedding: async () => {
25
- embeddingGenerationCalls++;
26
- return Array.from({ length: 8 }, (_, i) => i / 8);
27
- },
28
- EMBEDDING_DIMENSION: 8,
29
- embeddingModelName: () => "test-model",
30
- }));
31
-
32
- async function mkTmpDir(): Promise<string> {
33
- return fs.mkdtemp(path.join("/tmp/", "opencode-journal-"));
34
- }
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
-
46
- describe("loadConfig", () => {
47
- test("classifies a missing file and returns an empty config", async () => {
48
- const dir = await mkTmpDir();
49
- const loaded = await loadConfig(dir);
50
- expect(loaded.status).toBe("missing");
51
- expect(loaded.config).toEqual({});
52
- });
53
-
54
- test("classifies an unreadable file and returns an empty config", async () => {
55
- const dir = await mkTmpDir();
56
- await fs.mkdir(path.join(dir, "agent-memory.json"));
57
- const loaded = await loadConfig(dir);
58
- expect(loaded.status).toBe("unreadable");
59
- expect(loaded.config).toEqual({});
60
- });
61
-
62
- test("classifies malformed JSON and returns an empty config", async () => {
63
- const dir = await mkTmpDir();
64
- await fs.writeFile(path.join(dir, "agent-memory.json"), "not json{{{");
65
- const loaded = await loadConfig(dir);
66
- expect(loaded.status).toBe("malformed");
67
- expect(loaded.config).toEqual({});
68
- });
69
-
70
- test("classifies schema-invalid JSON and returns an empty config", async () => {
71
- const dir = await mkTmpDir();
72
- await fs.writeFile(
73
- path.join(dir, "agent-memory.json"),
74
- JSON.stringify({ journal: { enabled: "yes" } }),
75
- );
76
- const loaded = await loadConfig(dir);
77
- expect(loaded.status).toBe("invalid");
78
- expect(loaded.config).toEqual({});
79
- });
80
-
81
- test("rejects an unrecognized journal key instead of silently enabling", async () => {
82
- const dir = await mkTmpDir();
83
- await fs.writeFile(path.join(dir, "agent-memory.json"), JSON.stringify({ journal: { enabled_: false } }));
84
- const loaded = await loadConfig(dir);
85
- expect(loaded.status).toBe("invalid");
86
- expect(loaded.config).toEqual({});
87
- });
88
-
89
- test("classifies valid disabled config without changing it", async () => {
90
- const dir = await mkTmpDir();
91
- await fs.writeFile(
92
- path.join(dir, "agent-memory.json"),
93
- JSON.stringify({ journal: { enabled: false } }),
94
- );
95
- const loaded = await loadConfig(dir);
96
- expect(loaded.status).toBe("ok");
97
- expect(loaded.config).toEqual({ journal: { enabled: false } });
98
- });
99
-
100
- test("classifies valid enabled config without changing it", async () => {
101
- const dir = await mkTmpDir();
102
- await fs.writeFile(
103
- path.join(dir, "agent-memory.json"),
104
- JSON.stringify({ journal: { enabled: true } }),
105
- );
106
- const loaded = await loadConfig(dir);
107
- expect(loaded.status).toBe("ok");
108
- expect(loaded.config).toEqual({ journal: { enabled: true } });
109
- });
110
-
111
- test("returns custom tags from config", async () => {
112
- const dir = await mkTmpDir();
113
- await fs.writeFile(
114
- path.join(dir, "agent-memory.json"),
115
- JSON.stringify({
116
- journal: {
117
- enabled: true,
118
- tags: [
119
- { name: "perf", description: "Performance optimization work" },
120
- { name: "debug", description: "Debugging sessions" },
121
- ],
122
- },
123
- }),
124
- );
125
- const loaded = await loadConfig(dir);
126
- expect(loaded.config.journal?.tags).toEqual([
127
- { name: "perf", description: "Performance optimization work" },
128
- { name: "debug", description: "Debugging sessions" },
129
- ]);
130
- });
131
- });
132
-
133
- describe("journal store", () => {
134
- let tmpDir: string;
135
-
136
- afterEach(async () => {
137
- embeddingGenerationCalls = 0;
138
- lexicalIndexConstructions = 0;
139
- if (tmpDir) {
140
- await fs.rm(tmpDir, { recursive: true, force: true });
141
- }
142
- });
143
-
144
- test("write creates entry file with correct metadata", async () => {
145
- tmpDir = await mkTmpDir();
146
- const store = createJournalStore(tmpDir);
147
-
148
- const entry = await store.write({
149
- title: "Test insight",
150
- body: "Discovered an interesting pattern.",
151
- project: "/home/user/project",
152
- model: "claude-opus-4-6",
153
- provider: "anthropic",
154
- tags: ["testing"],
155
- });
156
-
157
- expect(entry.title).toBe("Test insight");
158
- expect(entry.project).toBe("/home/user/project");
159
- expect(entry.model).toBe("claude-opus-4-6");
160
- expect(entry.provider).toBe("anthropic");
161
- expect(entry.tags).toEqual(["testing"]);
162
- expect(entry.body).toBe("Discovered an interesting pattern.");
163
- expect(entry.id).toMatch(/^\d{8}-\d{6}-\d{3}$/);
164
-
165
- // Verify file exists
166
- const raw = await fs.readFile(entry.filePath, "utf-8");
167
- expect(raw).toContain("title: Test insight");
168
- });
169
-
170
- test("write generates chronological filenames", async () => {
171
- tmpDir = await mkTmpDir();
172
- const store = createJournalStore(tmpDir);
173
-
174
- const e1 = await store.write({ title: "First", body: "First entry" });
175
- // Small delay to ensure different timestamp
176
- await new Promise((r) => setTimeout(r, 5));
177
- const e2 = await store.write({ title: "Second", body: "Second entry" });
178
-
179
- expect(e2.id > e1.id).toBe(true);
180
- });
181
-
182
- test("write saves embedding file alongside entry", async () => {
183
- tmpDir = await mkTmpDir();
184
- const store = createJournalStore(tmpDir);
185
-
186
- const entry = await store.write({
187
- title: "Embedding test",
188
- body: "This should get an embedding.",
189
- });
190
-
191
- const embeddingFile = entry.filePath.replace(/\.md$/, ".embedding");
192
- const raw = await fs.readFile(embeddingFile, "utf-8");
193
- const embedding = JSON.parse(raw);
194
- // v0.4.0: versioned embedding object, not a bare array
195
- expect(embedding.v).toBe(2);
196
- expect(embedding.model).toBe("test-model");
197
- expect(embedding.dimension).toBe(8);
198
- expect(Array.isArray(embedding.vector)).toBe(true);
199
- expect(embedding.vector.length).toBeGreaterThan(0);
200
- });
201
-
202
- test("read returns full entry by id", async () => {
203
- tmpDir = await mkTmpDir();
204
- const store = createJournalStore(tmpDir);
205
-
206
- const written = await store.write({
207
- title: "Read test",
208
- body: "Read me back.",
209
- tags: ["test", "read"],
210
- });
211
-
212
- const read = await store.read(written.id);
213
- expect(read.title).toBe("Read test");
214
- expect(read.body).toBe("Read me back.");
215
- expect(read.tags).toEqual(["test", "read"]);
216
- });
217
-
218
- test("read throws for nonexistent id", async () => {
219
- tmpDir = await mkTmpDir();
220
- const store = createJournalStore(tmpDir);
221
-
222
- expect(store.read("99990101-000000-000")).rejects.toThrow(
223
- "Journal entry not found",
224
- );
225
- });
226
-
227
- test("read rejects path traversal", async () => {
228
- tmpDir = await mkTmpDir();
229
- const store = createJournalStore(tmpDir);
230
-
231
- expect(store.read("../../../etc/passwd")).rejects.toThrow(
232
- "Invalid journal entry ID",
233
- );
234
- });
235
-
236
- test("search with no filters returns recent entries", async () => {
237
- tmpDir = await mkTmpDir();
238
- const store = createJournalStore(tmpDir);
239
-
240
- await store.write({ title: "Entry 1", body: "First" });
241
- await new Promise((r) => setTimeout(r, 5));
242
- await store.write({ title: "Entry 2", body: "Second" });
243
- await new Promise((r) => setTimeout(r, 5));
244
- await store.write({ title: "Entry 3", body: "Third" });
245
-
246
- const result = await store.search({});
247
- expect(result.total).toBe(3);
248
- expect(result.entries.length).toBe(3);
249
- // Newest first (by recency score)
250
- expect(result.entries[0]!.title).toBe("Entry 3");
251
- expect(result.entries[2]!.title).toBe("Entry 1");
252
- });
253
-
254
- test("search filters by tags", async () => {
255
- tmpDir = await mkTmpDir();
256
- const store = createJournalStore(tmpDir);
257
-
258
- await store.write({
259
- title: "Tagged",
260
- body: "...",
261
- tags: ["rust", "perf"],
262
- });
263
- await new Promise((r) => setTimeout(r, 5));
264
- await store.write({
265
- title: "Other",
266
- body: "...",
267
- tags: ["python"],
268
- });
269
-
270
- const result = await store.search({ tags: ["rust"] });
271
- expect(result.total).toBe(1);
272
- expect(result.entries[0]!.title).toBe("Tagged");
273
- });
274
-
275
- test("search filters by project", async () => {
276
- tmpDir = await mkTmpDir();
277
- const store = createJournalStore(tmpDir);
278
-
279
- await store.write({ title: "Project A", body: "...", project: "/proj/a" });
280
- await new Promise((r) => setTimeout(r, 5));
281
- await store.write({ title: "Project B", body: "...", project: "/proj/b" });
282
-
283
- const result = await store.search({ project: "/proj/a" });
284
- expect(result.total).toBe(1);
285
- expect(result.entries[0]!.title).toBe("Project A");
286
- });
287
-
288
- test("search combines filters with AND logic", async () => {
289
- tmpDir = await mkTmpDir();
290
- const store = createJournalStore(tmpDir);
291
-
292
- await store.write({
293
- title: "Match",
294
- body: "...",
295
- tags: ["rust"],
296
- project: "/proj/a",
297
- });
298
- await new Promise((r) => setTimeout(r, 5));
299
- await store.write({
300
- title: "Wrong tags",
301
- body: "...",
302
- tags: ["python"],
303
- project: "/proj/a",
304
- });
305
- await new Promise((r) => setTimeout(r, 5));
306
- await store.write({
307
- title: "Wrong project",
308
- body: "...",
309
- tags: ["rust"],
310
- project: "/proj/b",
311
- });
312
-
313
- const result = await store.search({
314
- tags: ["rust"],
315
- project: "/proj/a",
316
- });
317
- expect(result.total).toBe(1);
318
- expect(result.entries[0]!.title).toBe("Match");
319
- });
320
-
321
- test("search respects limit", async () => {
322
- tmpDir = await mkTmpDir();
323
- const store = createJournalStore(tmpDir);
324
-
325
- for (let i = 0; i < 5; i++) {
326
- await store.write({ title: `Entry ${i}`, body: `Body ${i}` });
327
- await new Promise((r) => setTimeout(r, 5));
328
- }
329
-
330
- const result = await store.search({ limit: 2 });
331
- expect(result.entries.length).toBe(2);
332
- expect(result.total).toBe(5);
333
- });
334
-
335
- test("search supports offset pagination", async () => {
336
- tmpDir = await mkTmpDir();
337
- const store = createJournalStore(tmpDir);
338
-
339
- for (let i = 0; i < 5; i++) {
340
- await store.write({ title: `Entry ${i}`, body: `Body ${i}` });
341
- await new Promise((r) => setTimeout(r, 5));
342
- }
343
-
344
- const page1 = await store.search({ limit: 2, offset: 0 });
345
- expect(page1.entries.length).toBe(2);
346
- expect(page1.total).toBe(5);
347
- // Newest first
348
- expect(page1.entries[0]!.title).toBe("Entry 4");
349
- expect(page1.entries[1]!.title).toBe("Entry 3");
350
-
351
- const page2 = await store.search({ limit: 2, offset: 2 });
352
- expect(page2.entries.length).toBe(2);
353
- expect(page2.total).toBe(5);
354
- expect(page2.entries[0]!.title).toBe("Entry 2");
355
- expect(page2.entries[1]!.title).toBe("Entry 1");
356
-
357
- const page3 = await store.search({ limit: 2, offset: 4 });
358
- expect(page3.entries.length).toBe(1);
359
- expect(page3.entries[0]!.title).toBe("Entry 0");
360
-
361
- // Past the end
362
- const page4 = await store.search({ limit: 2, offset: 10 });
363
- expect(page4.entries.length).toBe(0);
364
- expect(page4.total).toBe(5);
365
- });
366
-
367
- test("search handles empty journal directory", async () => {
368
- tmpDir = await mkTmpDir();
369
- const store = createJournalStore(tmpDir);
370
-
371
- const result = await store.search({});
372
- expect(result.entries.length).toBe(0);
373
- expect(result.total).toBe(0);
374
- expect(result.allTags).toEqual([]);
375
- });
376
-
377
- test("search returns allTags from all entries regardless of filters", async () => {
378
- tmpDir = await mkTmpDir();
379
- const store = createJournalStore(tmpDir);
380
-
381
- await store.write({
382
- title: "Rust work",
383
- body: "...",
384
- tags: ["rust", "perf"],
385
- });
386
- await new Promise((r) => setTimeout(r, 5));
387
- await store.write({
388
- title: "Python work",
389
- body: "...",
390
- tags: ["python", "testing"],
391
- });
392
-
393
- // Filter to only rust tag — allTags should still include all tags
394
- const result = await store.search({ tags: ["rust"] });
395
- expect(result.total).toBe(1);
396
- expect(result.allTags).toEqual(["perf", "python", "rust", "testing"]);
397
- });
398
-
399
- test("search allTags deduplicates across entries", async () => {
400
- tmpDir = await mkTmpDir();
401
- const store = createJournalStore(tmpDir);
402
-
403
- await store.write({ title: "A", body: "...", tags: ["rust", "perf"] });
404
- await new Promise((r) => setTimeout(r, 5));
405
- await store.write({ title: "B", body: "...", tags: ["rust", "debugging"] });
406
-
407
- const result = await store.search({});
408
- expect(result.allTags).toEqual(["debugging", "perf", "rust"]);
409
- });
410
-
411
- test("search by text uses lexical OR matching", async () => {
412
- tmpDir = await mkTmpDir();
413
- const store = createJournalStore(tmpDir);
414
-
415
- await store.write({
416
- title: "Rust performance",
417
- body: "Optimized the hot loop using SIMD instructions.",
418
- });
419
- await new Promise((r) => setTimeout(r, 5));
420
- await store.write({
421
- title: "Python testing",
422
- body: "Set up pytest with coverage reporting.",
423
- });
424
-
425
- const result = await store.search({ text: "Rust absentterm" });
426
- expect(result.entries.map((entry) => entry.title)).toEqual(["Rust performance"]);
427
- });
428
-
429
- test("title query matches terms from the title", async () => {
430
- tmpDir = await mkTmpDir();
431
- const store = createJournalStore(tmpDir);
432
- const entry = await store.write({
433
- title: "Agent-memory release delivery channel",
434
- body: "Unrelated topics include coastal erosion, orbital mechanics, ceramic glazing, " +
435
- "database indexing, ancient trade routes, orchard irrigation, keyboard switches, " +
436
- "bird migration, public transit planning, and archival paper chemistry. These notes " +
437
- "continue across varied subjects in this deliberately long journal entry. " +
438
- "Further unrelated details cover weather instruments, typography, fermentation, " +
439
- "geology, woodworking, astronomy, logistics, and museum conservation. The entry is " +
440
- "deliberately verbose and does not repeat the title terms in its body, while " +
441
- "its title remains the searchable anchor for this regression case.",
442
- });
443
-
444
- const result = await store.search({ text: "Agent-memory release delivery channel" });
445
- expect(result.entries.some((candidate) => candidate.id === entry.id)).toBe(true);
446
- });
447
-
448
- test("lexical matching finds a rare exact token", async () => {
449
- tmpDir = await mkTmpDir();
450
- const store = createJournalStore(tmpDir);
451
- const entry = await store.write({ title: "Unrelated note", body: "The rarelexicaltoken appears here." });
452
- const query = "rarelexicaltoken";
453
-
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]);
457
- });
458
-
459
- test("OR matching returns unique matches and stable pagination across identical calls", async () => {
460
- tmpDir = await mkTmpDir();
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
- }
466
-
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);
475
- });
476
-
477
- test("identical text searches reuse the lexical index snapshot", async () => {
478
- tmpDir = await mkTmpDir();
479
- const store = createJournalStore(tmpDir);
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);
486
- });
487
-
488
- test("adding an entry rebuilds the index and makes it searchable", async () => {
489
- tmpDir = await mkTmpDir();
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
- });
499
-
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);
544
- });
545
-
546
- test("OR rankings and totals match an uncached MiniSearch fixture", async () => {
547
- tmpDir = await mkTmpDir();
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
- });
580
-
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
- });
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;
599
-
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);
603
- });
604
-
605
- test("text search matches query terms with OR and orders score, creation time, then id descending", async () => {
606
- tmpDir = await mkTmpDir();
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
- }
618
-
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
- });
623
-
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;
632
-
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);
637
- });
638
- });
@@ -1,39 +0,0 @@
1
- import { expect, mock, test } from "bun:test";
2
- import * as fs from "node:fs/promises";
3
- import * as path from "node:path";
4
- import * as realEmbeddings from "./embeddings";
5
-
6
- if (process.env.HOME === undefined || !process.env.HOME.startsWith("/tmp/")) {
7
- throw new Error("plugin tests require HOME pointed at a /tmp fixture directory; run via `bun run test:plugin`");
8
- }
9
-
10
- const configDir = path.join(process.env.HOME, ".config", "opencode");
11
-
12
- mock.module("./embeddings", () => ({
13
- ...realEmbeddings,
14
- generateEmbedding: async () => Array.from({ length: 384 }, (_, i) => Math.sin(i) * 0.5),
15
- warmupEmbedder: async () => {},
16
- }));
17
-
18
- import plugin from "./plugin";
19
-
20
- test("explicit opt-out registers no tools and emits no warning", async () => {
21
- await fs.mkdir(configDir, { recursive: true });
22
- await fs.writeFile(path.join(configDir, "agent-memory.json"), JSON.stringify({ journal: { enabled: false } }), "utf-8");
23
- const definitions: unknown[] = [];
24
- const warn = console.warn;
25
- let warnings = 0;
26
- console.warn = () => { warnings++; };
27
- try {
28
- await plugin.setup({
29
- location: { directory: "/tmp" },
30
- session: { hook: async () => {} },
31
- tool: { transform: async (callback: (editor: { add: (tool: unknown) => void; list: () => unknown[] }) => void) => callback({ add: (tool) => definitions.push(tool), list: () => [] }) },
32
- } as any);
33
- expect(definitions).toEqual([]);
34
- expect(warnings).toBe(0);
35
- } finally {
36
- console.warn = warn;
37
- await fs.rm(configDir, { recursive: true, force: true });
38
- }
39
- });
@@ -1,220 +0,0 @@
1
- import { afterEach, describe, expect, mock, test } from "bun:test";
2
- import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
- import * as realEmbeddings from "./embeddings";
6
-
7
- // These tests run in their own bun process (package.json "test:plugin") with
8
- // HOME pointed at a throwaway fixture directory, so the real loadConfig and
9
- // createJournalStore exercise real fixture files. Refuse to run against the
10
- // real home directory. Only the model-loading surface of ./embeddings is
11
- // stubbed, spreading the real module so other exports keep real values.
12
- if (process.env.HOME === undefined || !process.env.HOME.startsWith("/tmp/")) {
13
- throw new Error("plugin tests require HOME pointed at a /tmp fixture directory; run via `bun run test:plugin`");
14
- }
15
-
16
- const fixtureHome = process.env.HOME;
17
- const configDir = path.join(fixtureHome, ".config", "opencode");
18
- const configPath = path.join(configDir, "agent-memory.json");
19
- const journalDir = path.join(configDir, "journal");
20
-
21
- mock.module("./embeddings", () => ({
22
- ...realEmbeddings,
23
- generateEmbedding: async () => Array.from({ length: 384 }, (_, i) => Math.sin(i) * 0.5),
24
- warmupEmbedder: async () => {},
25
- }));
26
-
27
- import plugin from "./plugin";
28
-
29
- // value === undefined creates a DIRECTORY at the config path (unreadable);
30
- // a string is written raw (malformed JSON); an object is JSON-encoded.
31
- async function writeConfig(value: unknown): Promise<void> {
32
- await fs.mkdir(configDir, { recursive: true });
33
- if (value === undefined) {
34
- await fs.mkdir(configPath, { recursive: true });
35
- } else if (typeof value === "string") {
36
- await fs.writeFile(configPath, value, "utf-8");
37
- } else {
38
- await fs.writeFile(configPath, JSON.stringify(value), "utf-8");
39
- }
40
- }
41
-
42
- function harness(directory = "/tmp/project") {
43
- const definitions = new Map<string, any>();
44
- let contextHook: ((event: any) => void) | undefined;
45
- let transform: ((editor: { add: (definition: any) => void; list: () => any[] }) => void) | undefined;
46
- const context = {
47
- location: { directory },
48
- session: { hook: async (name: string, callback: (event: any) => void) => { expect(name).toBe("context"); contextHook = callback; } },
49
- tool: { transform: async (callback: typeof transform) => { transform = callback; callback!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }); } },
50
- } as any;
51
- return { definitions, contextHook: () => contextHook, replay: () => transform!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }), start: () => plugin.setup(context) };
52
- }
53
-
54
- afterEach(async () => {
55
- await fs.rm(configDir, { recursive: true, force: true });
56
- });
57
-
58
- describe("v2 plugin registration", () => {
59
- test("has a stable id and default-registers exactly the three journal tools", async () => {
60
- expect(plugin.id).toBe("opencode-agent-memory");
61
- await fs.rm(configDir, { recursive: true, force: true }); // absent config: default-on journal tools
62
- const h = harness();
63
- await h.start();
64
- expect([...h.definitions.keys()].sort()).toEqual(["journal_read", "journal_search", "journal_write"]);
65
- });
66
-
67
- test("registered write description suggests configured tag names without their descriptions", async () => {
68
- await writeConfig({ journal: { tags: [
69
- { name: "owner_approved", description: "Use for a choice made and its rationale." },
70
- { name: "incident_followup", description: "Use for investigation notes and findings." },
71
- ] } });
72
- const h = harness();
73
- await h.start();
74
- const description = h.definitions.get("journal_write").description as string;
75
- expect(description).toContain("Suggested tags: owner_approved, incident_followup.");
76
- expect(description).not.toContain("Use for a choice made and its rationale.");
77
- expect(description).not.toContain("Use for investigation notes and findings.");
78
- });
79
-
80
- test("transform replay does not duplicate tool registration", async () => {
81
- const h = harness(); await h.start();
82
- h.replay();
83
- expect([...h.definitions.keys()].sort()).toEqual(["journal_read", "journal_search", "journal_write"]);
84
- });
85
-
86
- test("explicit false opts out without tools", async () => {
87
- await writeConfig({ journal: { enabled: false } });
88
- const h = harness(); await h.start();
89
- expect([...h.definitions.keys()]).toEqual([]);
90
- });
91
-
92
- test("invalid journal typo fails closed and warns", async () => {
93
- await writeConfig({ journal: { enabled_: false } });
94
- const warnings: unknown[][] = [];
95
- const originalWarn = console.warn;
96
- console.warn = (...args) => warnings.push(args);
97
- try {
98
- const h = harness(); await h.start();
99
- expect([...h.definitions.keys()]).toEqual([]);
100
- expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: schema validation failed; journal tools will NOT be registered`]]);
101
- } finally { console.warn = originalWarn; }
102
- });
103
-
104
- test("fails closed on unreadable config", async () => {
105
- await writeConfig(undefined);
106
- const warnings: unknown[][] = [];
107
- const originalWarn = console.warn;
108
- console.warn = (...args) => warnings.push(args);
109
- try {
110
- const h = harness(); await h.start();
111
- expect([...h.definitions.keys()]).toEqual([]);
112
- expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: read failed (EISDIR); journal tools will NOT be registered`]]);
113
- } finally { console.warn = originalWarn; }
114
- });
115
-
116
- test("fails closed on malformed config", async () => {
117
- await writeConfig("not json{{{");
118
- const warnings: unknown[][] = [];
119
- const originalWarn = console.warn;
120
- console.warn = (...args) => warnings.push(args);
121
- try {
122
- const h = harness(); await h.start();
123
- expect([...h.definitions.keys()]).toEqual([]);
124
- expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: malformed JSON; journal tools will NOT be registered`]]);
125
- } finally { console.warn = originalWarn; }
126
- });
127
-
128
- test("fails closed on invalid config", async () => {
129
- await writeConfig({ journal: { enabled: "yes" } });
130
- const warnings: unknown[][] = [];
131
- const originalWarn = console.warn;
132
- console.warn = (...args) => warnings.push(args);
133
- try {
134
- const h = harness(); await h.start();
135
- expect([...h.definitions.keys()]).toEqual([]);
136
- expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: schema validation failed; journal tools will NOT be registered`]]);
137
- } finally { console.warn = originalWarn; }
138
- });
139
-
140
- test("default absent config emits no warning", async () => {
141
- const warnings: unknown[][] = [];
142
- const originalWarn = console.warn;
143
- console.warn = (...args) => warnings.push(args);
144
- try { const h = harness(); await h.start(); expect(warnings).toEqual([]); }
145
- finally { console.warn = originalWarn; }
146
- });
147
-
148
- test("captures model/provider per session for interleaved context events", async () => {
149
- const h = harness(); await h.start();
150
- const hook = h.contextHook()!;
151
- hook({ sessionID: "session-a", model: { id: "model-a", providerID: "provider-a" } });
152
- hook({ sessionID: "session-b", model: { id: "model-b", providerID: "provider-b" } });
153
- hook({ sessionID: "session-a", model: { id: "model-a2", providerID: "provider-a2" } });
154
- await h.definitions.get("journal_write").execute({ title: "Title", body: "Body" }, { agent: "agent", sessionID: "session-b" });
155
- const files = (await fs.readdir(journalDir)).filter((f) => f.endsWith(".md"));
156
- expect(files).toHaveLength(1);
157
- const text = await fs.readFile(path.join(journalDir, files[0]!), "utf-8");
158
- expect(text).toContain("model: model-b");
159
- expect(text).toContain("provider: provider-b");
160
- expect(text).toContain("session_id: session-b");
161
- });
162
- });
163
-
164
- describe("project journal index injection", () => {
165
- const event = () => ({ sessionID: "s", model: { id: "m", providerID: "p" }, system: [] as Array<{ type: "text"; text: string }> });
166
- async function project(body?: string) {
167
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), "memory-index-"));
168
- if (body !== undefined) await fs.writeFile(path.join(directory, "MEMORY.md"), body);
169
- return directory;
170
- }
171
-
172
- test("injects a byte-stable rendered index at exact word fit", async () => {
173
- const directory = await project(Array(330).fill("pointer").join(" "));
174
- try {
175
- const h = harness(directory); await h.start();
176
- const first = event(); const second = event();
177
- await h.contextHook()!(first); await h.contextHook()!(second);
178
- expect(first.system).toEqual(second.system);
179
- expect(first.system).toHaveLength(1);
180
- expect(first.system[0]!.text).toBe(`This is the project's journal-pointer index (MEMORY.md at the repository root). Retrieve entries with journal_search/journal_read; older lines remain relevant.\n\n${Array(330).fill("pointer").join(" ")}`);
181
- } finally { await fs.rm(directory, { recursive: true, force: true }); }
182
- });
183
-
184
- test("331 words injects only the fixed notice", async () => {
185
- const directory = await project(Array(331).fill("pointer").join(" "));
186
- try { const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system).toEqual([{ type: "text", text: "Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list." }]); }
187
- finally { await fs.rm(directory, { recursive: true, force: true }); }
188
- });
189
-
190
- test("empty, missing, and unreadable indexes inject nothing", async () => {
191
- const empty = await project(" \n\t");
192
- const missing = await project();
193
- const unreadable = path.join(os.tmpdir(), `memory-index-file-${Date.now()}`);
194
- await fs.writeFile(unreadable, "not a directory");
195
- try {
196
- for (const directory of [empty, missing, unreadable]) {
197
- const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system).toEqual([]);
198
- }
199
- } finally {
200
- await fs.rm(empty, { recursive: true, force: true });
201
- await fs.rm(missing, { recursive: true, force: true });
202
- await fs.rm(unreadable, { force: true });
203
- }
204
- });
205
-
206
- test("strips frontmatter; enforces UTF-8 byte cap for long Unicode tokens", async () => {
207
- const directory = await project(`---\ntitle: hidden\n---\nvisible pointer`);
208
- try { const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system[0]!.text).toContain("\n\nvisible pointer"); expect(e.system[0]!.text).not.toContain("hidden"); }
209
- finally { await fs.rm(directory, { recursive: true, force: true }); }
210
- const large = await project("界".repeat(8192));
211
- try { const h = harness(large); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system[0]!.text).toBe("Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list."); }
212
- finally { await fs.rm(large, { recursive: true, force: true }); }
213
- });
214
-
215
- test("disabled journal registers no hook or injection", async () => {
216
- const directory = await project("pointer");
217
- try { await writeConfig({ journal: { enabled: false } }); const h = harness(directory); await h.start(); expect(h.contextHook()).toBeUndefined(); }
218
- finally { await fs.rm(directory, { recursive: true, force: true }); }
219
- });
220
- });
@@ -1,15 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import * as fs from "node:fs/promises";
3
-
4
- // Guards the two-process test split in package.json: every test file in
5
- // src/ must be listed in exactly one group, or it silently never runs.
6
- const CORE = ["doctor.test.ts", "embeddings.test.ts", "journal.test.ts", "test-suite.test.ts", "tools.test.ts"];
7
- const PLUGIN = ["plugin-disabled.test.ts", "plugin.test.ts"];
8
-
9
- test("every test file is covered by the test:core/test:plugin split", async () => {
10
- const files = (await fs.readdir(import.meta.dir))
11
- .filter((f) => f.endsWith(".test.ts"))
12
- .sort();
13
- const covered = [...CORE, ...PLUGIN].sort();
14
- expect(files).toEqual(covered);
15
- });
package/src/tools.test.ts DELETED
@@ -1,115 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { JournalRead, JournalSearch, JournalWrite } from "./tools";
3
- import type { JournalContext } from "./tools";
4
- import type { JournalEntry, JournalStore } from "./journal";
5
-
6
- const context: JournalContext = { directory: "/tmp/project", model: "m", provider: "p" };
7
- function entry(): JournalEntry {
8
- return { id: "20260101-000000-000", title: "Title", project: "/tmp", model: "m", provider: "p", agent: "a", sessionId: "s", created: new Date("2026-01-01T00:00:00.000Z"), tags: ["tag"], body: "Body", filePath: "/tmp/entry.md" };
9
- }
10
- const baseStore: JournalStore = {
11
- write: async () => entry(),
12
- read: async () => entry(),
13
- search: async () => ({ entries: [entry()], total: 1, allTags: ["tag"] }),
14
- };
15
-
16
- function valid(schema: any, value: any): boolean {
17
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
18
- if (schema.additionalProperties === false && Object.keys(value).some((key) => !(key in schema.properties))) return false;
19
- if (schema.required?.some((key: string) => !(key in value))) return false;
20
- for (const [key, field] of Object.entries<any>(schema.properties)) {
21
- if (!(key in value)) continue;
22
- const actual = value[key];
23
- if (field.type === "string" && typeof actual !== "string") return false;
24
- if (field.type === "integer" && (!Number.isInteger(actual) || actual < (field.minimum ?? -Infinity))) return false;
25
- }
26
- return true;
27
- }
28
-
29
- describe("v2 journal tools", () => {
30
- test("preserves the recognizable names and descriptions", () => {
31
- expect([JournalWrite(baseStore, context).name, JournalRead(baseStore).name, JournalSearch(baseStore).name]).toEqual(["journal_write", "journal_read", "journal_search"]);
32
- expect(JournalWrite(baseStore, context).description).toContain("append-only");
33
- expect(JournalRead(baseStore).description).toContain("by its ID");
34
- expect(JournalSearch(baseStore).description).toContain("lexical OR matching");
35
- });
36
-
37
- test("journal_write description preserves its behavioral guidance", () => {
38
- const description = JournalWrite(baseStore, context).description;
39
- expect(description).toMatch(/insights.*discoveries|discoveries.*decisions/i);
40
- expect(description).toMatch(/insights.*technical discoveries.*design decisions.*observations/i);
41
- expect(description).toMatch(/never edit old ones/i);
42
- expect(description).toMatch(/comma-separated/i);
43
- expect(description).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
44
- expect(description).not.toContain("Suggested tags:");
45
- const tagged = JournalWrite(baseStore, context, [{ name: "focus", description: "private detail" }]).description;
46
- expect(tagged).toContain("Suggested tags: focus.");
47
- expect(tagged).not.toContain("private detail");
48
- });
49
-
50
- test("read and search descriptions retain their distinct contracts", () => {
51
- const read = JournalRead(baseStore).description;
52
- expect(read).not.toMatch(/append.only|cannot be (edited|modified)/i);
53
- expect(read).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
54
- const search = JournalSearch(baseStore);
55
- expect(search.description).toMatch(/filters are optional/i);
56
- expect(search.description).toMatch(/global across all projects/i);
57
- expect(search.input.properties).toHaveProperty("project");
58
- expect(search.input.properties).toHaveProperty("offset");
59
- expect(search.description).toMatch(/paginate/i);
60
- });
61
-
62
- test("descriptions are nonempty and do not name sibling tools", () => {
63
- const descriptions = [JournalWrite(baseStore, context).description, JournalRead(baseStore).description, JournalSearch(baseStore).description];
64
- expect(descriptions.every((description) => description.trim().length > 0)).toBe(true);
65
- for (const [index, description] of descriptions.entries()) {
66
- for (const [otherIndex, name] of ["journal_write", "journal_read", "journal_search"].entries()) {
67
- if (index !== otherIndex) expect(description).not.toContain(name);
68
- }
69
- }
70
- });
71
-
72
- test("uses JSON schemas, rejects malformed and unknown arguments", () => {
73
- const write = JournalWrite(baseStore, context).input as any;
74
- const read = JournalRead(baseStore).input as any;
75
- const search = JournalSearch(baseStore).input as any;
76
- expect(write.type).toBe("object");
77
- expect(valid(write, { title: "t", body: "b" })).toBe(true);
78
- expect(valid(write, { title: 4, body: "b" })).toBe(false);
79
- expect(valid(write, { title: "t", body: "b", extra: true })).toBe(false);
80
- expect(valid(read, {})).toBe(false);
81
- expect(valid(read, { id: "id", extra: 1 })).toBe(false);
82
- expect(valid(search, { limit: 0 })).toBe(false);
83
- expect(valid(search, { offset: 1.5 })).toBe(false);
84
- expect(valid(search, { unexpected: true })).toBe(false);
85
- expect(valid(search, { tags: "a,b", limit: 2, offset: 0 })).toBe(true);
86
- });
87
-
88
- test("all execute methods return the v2 {content} result", async () => {
89
- const writeResult = await JournalWrite(baseStore, context).execute({ title: "t", body: "b" }, { agent: "a", sessionID: "s" });
90
- const readResult = await JournalRead(baseStore).execute({ id: "entry" }, {} as any);
91
- const searchResult = await JournalSearch(baseStore).execute({}, {} as any);
92
- for (const result of [writeResult, readResult, searchResult]) {
93
- expect(Object.keys(result)).toEqual(["content"]);
94
- expect(typeof result.content).toBe("string");
95
- }
96
- expect(writeResult.content).toContain("Journal entry created:");
97
- expect(readResult.content).toContain("Body");
98
- expect(searchResult.content).toContain("Found 1 candidate entries");
99
- });
100
-
101
- test("retains bounded tag inventory output formatting", async () => {
102
- const populated: JournalStore = { ...baseStore, search: async () => ({ entries: [entry()], total: 1, allTags: ["alpha", "beta"] }) };
103
- expect((await JournalSearch(populated).execute({}, {} as any)).content).toContain("Tags in use: alpha, beta");
104
- const empty: JournalStore = { ...baseStore, search: async () => ({ entries: [], total: 0, allTags: [] }) };
105
- expect((await JournalSearch(empty).execute({}, {} as any)).content).toBe("No journal entries found.");
106
- const store: JournalStore = { ...baseStore, search: async () => ({ entries: [], total: 0, allTags: Array.from({ length: 21 }, (_, i) => `tag-${i}`) }) };
107
- const result = await JournalSearch(store).execute({}, {} as any);
108
- expect(result.content).toBe("No journal entries found.\nTags in use: 21 (filter with tags=...)");
109
- });
110
-
111
- test("journal_read formats metadata before the body", async () => {
112
- const result = await JournalRead(baseStore).execute({ id: "entry" }, {} as any);
113
- expect(result.content).toBe("title: Title\ncreated: 2026-01-01T00:00:00.000Z\nproject: /tmp\nmodel: m\nprovider: p\nagent: a\nsession: s\ntags: tag\n\nBody");
114
- });
115
- });