@ghilteras/opencode-agent-memory 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
32
32
 
33
33
  ```json
34
34
  {
35
- "plugins": ["@ghilteras/opencode-agent-memory@1.0.0"]
35
+ "plugins": ["@ghilteras/opencode-agent-memory@1.1.0"]
36
36
  }
37
37
  ```
38
38
 
@@ -156,3 +156,11 @@ Originally created by Joshua David Thomas and licensed under the MIT license. Ma
156
156
  opencode-agent-memory is not built by, or affiliated with, the OpenCode team.
157
157
 
158
158
  OpenCode is ©2025 Anomaly.
159
+ # Journal diagnostics
160
+
161
+ Run `bun run journal:doctor [config-dir]` to inspect flat journal Markdown files
162
+ and their `.embedding` sidecars without repairing or changing them. The optional
163
+ directory is the OpenCode state/config directory (the journal is read from its
164
+ `journal/` subdirectory); without it, the default is `~/.config/opencode`.
165
+ The command prints a deterministic JSON report and exits 0 for a complete scan
166
+ without errors, 1 for validation errors, or 2 for an incomplete scan/root error.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghilteras/opencode-agent-memory",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
@@ -17,8 +17,11 @@
17
17
  "src"
18
18
  ],
19
19
  "scripts": {
20
- "test": "bun test",
21
- "typecheck": "tsc --noEmit"
20
+ "test": "bun run test:core && bun run test:plugin",
21
+ "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",
22
+ "test:plugin": "HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 HOME=$(mktemp -d) bun test src/plugin.test.ts src/plugin-disabled.test.ts",
23
+ "typecheck": "tsc --noEmit",
24
+ "journal:doctor": "bun run src/journal-doctor.ts"
22
25
  },
23
26
  "dependencies": {
24
27
  "@huggingface/transformers": "^3.8.1",
@@ -0,0 +1,183 @@
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
+ });
package/src/doctor.ts ADDED
@@ -0,0 +1,87 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import * as yaml from "js-yaml";
5
+ import { z } from "zod";
6
+ import { EMBEDDING_DIMENSION, embeddingModelName } from "./embeddings";
7
+ import { splitFrontmatter } from "./frontmatter";
8
+ import { EntryFrontmatterSchema } from "./journal";
9
+
10
+ export type JournalFinding = { path: string; code: string; severity: "error" | "warning" };
11
+ export type JournalDiagnosticReport = {
12
+ complete: boolean;
13
+ counts: { files: number; errors: number; warnings: number };
14
+ findings: JournalFinding[];
15
+ };
16
+ type FileSystem = Pick<typeof fs, "readdir" | "readFile" | "stat">;
17
+
18
+ export async function diagnoseJournal(configDir?: string, fileSystem: FileSystem = fs): Promise<JournalDiagnosticReport> {
19
+ const root = path.resolve(configDir ?? path.join(os.homedir(), ".config", "opencode"), "journal");
20
+ const findings: JournalFinding[] = [];
21
+ let complete = true;
22
+ const add = (file: string, code: string, severity: JournalFinding["severity"]) => findings.push({ path: file, code, severity });
23
+ let entries: import("node:fs").Dirent[];
24
+ try { entries = await fileSystem.readdir(root, { withFileTypes: true }); }
25
+ catch { add(".", "root_unreadable", "error"); complete = false; entries = []; }
26
+
27
+ const inventory = entries.filter((entry) => entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".embedding"))).map((entry) => entry.name).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
28
+ const files = inventory.filter((name) => name.endsWith(".md"));
29
+ const sidecars = inventory.filter((name) => name.endsWith(".embedding"));
30
+ const sidecarSet = new Set(sidecars);
31
+ for (const name of files) {
32
+ const relative = name;
33
+ let raw: string | undefined;
34
+ try { raw = await fileSystem.readFile(path.join(root, name), "utf8"); }
35
+ catch { add(relative, "file_unreadable", "error"); complete = false; }
36
+ if (raw !== undefined) {
37
+ const { frontmatterText } = splitFrontmatter(raw);
38
+ if (frontmatterText === undefined) add(relative, "frontmatter_missing_or_malformed", "error");
39
+ else {
40
+ let value: unknown;
41
+ try { value = yaml.load(frontmatterText); }
42
+ catch { add(relative, "yaml_malformed", "error"); value = undefined; }
43
+ {
44
+ const parsed = EntryFrontmatterSchema.safeParse(value);
45
+ if (!parsed.success) {
46
+ const fields = [...new Set(parsed.error.issues.map((issue) => String(issue.path[0] ?? "document")))].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
47
+ for (const field of fields) add(relative, `frontmatter_${field}_invalid`, "error");
48
+ } else {
49
+ if (parsed.data.created === undefined) add(relative, "created_missing", "warning");
50
+ else if (!Number.isFinite(new Date(parsed.data.created).getTime())) add(relative, "created_invalid", "error");
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ for (const sidecar of sidecars) {
58
+ const associatedMarkdown = `${sidecar.slice(0, -".embedding".length)}.md`;
59
+ if (!files.includes(associatedMarkdown)) add(sidecar, "embedding_orphan", "warning");
60
+ try {
61
+ const content = await fileSystem.readFile(path.join(root, sidecar), "utf8");
62
+ let value: unknown;
63
+ try { value = JSON.parse(content); } catch { add(sidecar, "embedding_json_malformed", "error"); continue; }
64
+ let vector: unknown; let dimension: unknown; let model: unknown; let legacy = false;
65
+ if (Array.isArray(value)) { vector = value; dimension = value.length; legacy = true; }
66
+ else if (value && typeof value === "object") {
67
+ const envelope = z.object({ v: z.literal(2), model: z.string(), dimension: z.number().int().positive(), vector: z.array(z.number().finite()).min(1) }).safeParse(value);
68
+ if (!envelope.success) { add(sidecar, "embedding_invalid", "error"); continue; }
69
+ ({ vector, dimension, model } = envelope.data);
70
+ } else { add(sidecar, "embedding_invalid", "error"); continue; }
71
+ if (!Array.isArray(vector) || vector.length === 0 || !vector.every((n) => typeof n === "number" && Number.isFinite(n)) || vector.length !== dimension) {
72
+ add(sidecar, "embedding_invalid", "error"); continue;
73
+ }
74
+ if (legacy) add(sidecar, "embedding_model_unknown", "warning");
75
+ if (vector.length !== EMBEDDING_DIMENSION || (!legacy && model !== embeddingModelName())) add(sidecar, "embedding_incompatible", "warning");
76
+ } catch (error) {
77
+ add(sidecar, "embedding_unreadable", "error"); complete = false;
78
+ }
79
+ }
80
+ for (const name of files) {
81
+ const expectedSidecar = `${name.slice(0, -".md".length)}.embedding`;
82
+ if (!sidecarSet.has(expectedSidecar)) add(expectedSidecar, "embedding_missing", "warning");
83
+ }
84
+ const compare = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
85
+ findings.sort((a, b) => compare(a.path, b.path) || compare(a.code, b.code) || compare(a.severity, b.severity));
86
+ return { complete, counts: { files: files.length, errors: findings.filter((f) => f.severity === "error").length, warnings: findings.filter((f) => f.severity === "warning").length }, findings };
87
+ }
@@ -0,0 +1,11 @@
1
+ import { diagnoseJournal } from "./doctor";
2
+
3
+ const args = process.argv.slice(2);
4
+ if (args.length > 1 || args.some((arg) => arg.startsWith("-"))) {
5
+ console.error("usage: bun run journal:doctor [config-dir]");
6
+ process.exitCode = 2;
7
+ } else {
8
+ const report = await diagnoseJournal(args[0]);
9
+ console.log(JSON.stringify(report));
10
+ process.exitCode = !report.complete ? 2 : report.counts.errors > 0 ? 1 : 0;
11
+ }
@@ -1,11 +1,13 @@
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";
4
5
 
5
- import { createJournalStore, loadConfig } from "./journal";
6
-
7
- // Mock the embeddings module to avoid downloading a real model in tests
6
+ // Mock the embeddings module to avoid downloading a real model in tests.
7
+ // Spread the real module so every export not overridden here keeps its real
8
+ // value regardless of test-file discovery order.
8
9
  mock.module("./embeddings", () => ({
10
+ ...realEmbeddings,
9
11
  generateEmbedding: async (text: string) => {
10
12
  // Deterministic fake embedding based on text content
11
13
  const hash = Array.from(text).reduce(
@@ -40,6 +42,16 @@ async function mkTmpDir(): Promise<string> {
40
42
  return fs.mkdtemp(path.join("/tmp/", "opencode-journal-"));
41
43
  }
42
44
 
45
+ // Bind the journal module AFTER the embeddings mock registration (above) via
46
+ // dynamic import, so the store uses the fake embeddings regardless of bun's
47
+ // module-mock rewiring semantics; a static import would be hoisted above it.
48
+ let createJournalStore!: (typeof import("./journal"))["createJournalStore"];
49
+ let loadConfig!: (typeof import("./journal"))["loadConfig"];
50
+
51
+ beforeAll(async () => {
52
+ ({ createJournalStore, loadConfig } = await import("./journal"));
53
+ });
54
+
43
55
  describe("loadConfig", () => {
44
56
  test("classifies a missing file and returns an empty config", async () => {
45
57
  const dir = await mkTmpDir();
@@ -521,8 +533,9 @@ describe("journal store", () => {
521
533
  body: "unique-sidecar-deleted-content",
522
534
  });
523
535
 
524
- // First search: caches the entry + sidecar fingerprint.
525
- const r1 = await store.search({ text: "unique-sidecar-deleted-content" });
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({});
526
539
  expect(r1.entries.some((e) => e.title === "Sidecar deleted entry")).toBe(true);
527
540
 
528
541
  // Delete the sidecar file.
package/src/journal.ts CHANGED
@@ -98,7 +98,7 @@ export type JournalTag = {
98
98
  description: string;
99
99
  };
100
100
 
101
- const EntryFrontmatterSchema = z.looseObject({
101
+ export const EntryFrontmatterSchema = z.looseObject({
102
102
  title: z.string().min(1),
103
103
  project: z.string().optional(),
104
104
  model: z.string().optional(),
@@ -1,15 +1,25 @@
1
- import { afterEach, expect, mock, test } from "bun:test";
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";
2
5
 
3
- mock.module("./journal", () => ({
4
- loadConfig: async () => ({ config: { journal: { enabled: false } }, status: "ok", configPath: "/tmp/disabled/agent-memory.json" }),
5
- createJournalStore: () => ({}),
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 () => {},
6
16
  }));
7
- mock.module("./embeddings", () => ({ warmupEmbedder: async () => {} }));
8
- import plugin from "./plugin";
9
17
 
10
- afterEach(() => mock.restore());
18
+ import plugin from "./plugin";
11
19
 
12
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");
13
23
  const definitions: unknown[] = [];
14
24
  const warn = console.warn;
15
25
  let warnings = 0;
@@ -18,9 +28,12 @@ test("explicit opt-out registers no tools and emits no warning", async () => {
18
28
  await plugin.setup({
19
29
  location: { directory: "/tmp" },
20
30
  session: { hook: async () => {} },
21
- tool: { transform: async (callback: (editor: { add: (tool: unknown) => void }) => void) => callback({ add: (tool) => definitions.push(tool) }) },
31
+ tool: { transform: async (callback: (editor: { add: (tool: unknown) => void; list: () => unknown[] }) => void) => callback({ add: (tool) => definitions.push(tool), list: () => [] }) },
22
32
  } as any);
23
33
  expect(definitions).toEqual([]);
24
34
  expect(warnings).toBe(0);
25
- } finally { console.warn = warn; }
35
+ } finally {
36
+ console.warn = warn;
37
+ await fs.rm(configDir, { recursive: true, force: true });
38
+ }
26
39
  });
@@ -1,66 +1,77 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
- import type { LoadedConfig, JournalEntry } from "./journal";
3
-
4
- let loadedConfig: LoadedConfig = { config: {}, status: "missing", configPath: "/tmp/test/agent-memory.json", reason: "file is missing" };
5
- const writes: Array<Record<string, unknown>> = [];
6
- mock.module("./journal", () => ({
7
- loadConfig: async () => loadedConfig,
8
- createJournalStore: () => ({
9
- write: async (entry: Record<string, unknown>) => {
10
- writes.push(entry);
11
- return { id: "id-1", title: entry.title, created: new Date("2026-01-01T00:00:00.000Z") } as JournalEntry;
12
- },
13
- read: async () => { throw new Error("unused"); },
14
- search: async () => ({ entries: [], total: 0, allTags: [] }),
15
- }),
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 () => {},
16
25
  }));
17
- mock.module("./embeddings", () => ({ warmupEmbedder: async () => {} }));
18
26
 
19
27
  import plugin from "./plugin";
20
28
 
21
- function harness() {
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") {
22
43
  const definitions = new Map<string, any>();
23
44
  let contextHook: ((event: any) => void) | undefined;
24
45
  let transform: ((editor: { add: (definition: any) => void; list: () => any[] }) => void) | undefined;
25
46
  const context = {
26
- location: { directory: "/tmp/project" },
47
+ location: { directory },
27
48
  session: { hook: async (name: string, callback: (event: any) => void) => { expect(name).toBe("context"); contextHook = callback; } },
28
49
  tool: { transform: async (callback: typeof transform) => { transform = callback; callback!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }); } },
29
50
  } as any;
30
51
  return { definitions, contextHook: () => contextHook, replay: () => transform!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }), start: () => plugin.setup(context) };
31
52
  }
32
53
 
33
- afterEach(() => {
34
- loadedConfig = { config: {}, status: "missing", configPath: "/tmp/test/agent-memory.json", reason: "file is missing" };
35
- writes.length = 0;
54
+ afterEach(async () => {
55
+ await fs.rm(configDir, { recursive: true, force: true });
36
56
  });
37
57
 
38
58
  describe("v2 plugin registration", () => {
39
59
  test("has a stable id and default-registers exactly the three journal tools", async () => {
40
60
  expect(plugin.id).toBe("opencode-agent-memory");
61
+ await fs.rm(configDir, { recursive: true, force: true }); // absent config: default-on journal tools
41
62
  const h = harness();
42
63
  await h.start();
43
64
  expect([...h.definitions.keys()].sort()).toEqual(["journal_read", "journal_search", "journal_write"]);
44
65
  });
45
66
 
46
67
  test("registered write description suggests configured tag names without their descriptions", async () => {
47
- loadedConfig = {
48
- config: {
49
- journal: {
50
- tags: [
51
- { name: "owner_approved", description: "Use for a choice made and its rationale." },
52
- { name: "incident_followup", description: "Use for investigation notes and findings." },
53
- ],
54
- },
55
- },
56
- status: "ok",
57
- configPath: "/tmp/test/agent-memory.json",
58
- };
59
-
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
+ ] } });
60
72
  const h = harness();
61
73
  await h.start();
62
74
  const description = h.definitions.get("journal_write").description as string;
63
-
64
75
  expect(description).toContain("Suggested tags: owner_approved, incident_followup.");
65
76
  expect(description).not.toContain("Use for a choice made and its rationale.");
66
77
  expect(description).not.toContain("Use for investigation notes and findings.");
@@ -73,36 +84,58 @@ describe("v2 plugin registration", () => {
73
84
  });
74
85
 
75
86
  test("explicit false opts out without tools", async () => {
76
- loadedConfig = { config: { journal: { enabled: false } }, status: "ok", configPath: "/tmp/test/agent-memory.json" };
87
+ await writeConfig({ journal: { enabled: false } });
77
88
  const h = harness(); await h.start();
78
89
  expect([...h.definitions.keys()]).toEqual([]);
79
90
  });
80
91
 
81
92
  test("invalid journal typo fails closed and warns", async () => {
82
- loadedConfig = { config: {}, status: "invalid", configPath: "/tmp/test/agent-memory.json", reason: "schema validation failed" };
93
+ await writeConfig({ journal: { enabled_: false } });
83
94
  const warnings: unknown[][] = [];
84
95
  const originalWarn = console.warn;
85
96
  console.warn = (...args) => warnings.push(args);
86
97
  try {
87
98
  const h = harness(); await h.start();
88
99
  expect([...h.definitions.keys()]).toEqual([]);
89
- expect(warnings).toEqual([["[agent-memory] journal config /tmp/test/agent-memory.json: schema validation failed; journal tools will NOT be registered"]]);
100
+ expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: schema validation failed; journal tools will NOT be registered`]]);
90
101
  } finally { console.warn = originalWarn; }
91
102
  });
92
103
 
93
- for (const status of ["unreadable", "malformed", "invalid"] as const) {
94
- test(`fails closed on ${status} config`, async () => {
95
- loadedConfig = { config: { journal: { enabled: true } }, status, configPath: "/tmp/test/agent-memory.json", reason: "bad config" };
96
- const warnings: unknown[][] = [];
97
- const originalWarn = console.warn;
98
- console.warn = (...args) => warnings.push(args);
99
- try {
100
- const h = harness(); await h.start();
101
- expect([...h.definitions.keys()]).toEqual([]);
102
- expect(warnings).toEqual([["[agent-memory] journal config /tmp/test/agent-memory.json: bad config; journal tools will NOT be registered"]]);
103
- } finally { console.warn = originalWarn; }
104
- });
105
- }
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
+ });
106
139
 
107
140
  test("default absent config emits no warning", async () => {
108
141
  const warnings: unknown[][] = [];
@@ -119,6 +152,69 @@ describe("v2 plugin registration", () => {
119
152
  hook({ sessionID: "session-b", model: { id: "model-b", providerID: "provider-b" } });
120
153
  hook({ sessionID: "session-a", model: { id: "model-a2", providerID: "provider-a2" } });
121
154
  await h.definitions.get("journal_write").execute({ title: "Title", body: "Body" }, { agent: "agent", sessionID: "session-b" });
122
- expect(writes[0]).toMatchObject({ model: "model-b", provider: "provider-b", 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 }); }
123
219
  });
124
220
  });
package/src/plugin.ts CHANGED
@@ -3,6 +3,14 @@ import { createJournalStore, loadConfig } from "./journal";
3
3
  import { JournalRead, JournalSearch, JournalWrite } from "./tools";
4
4
  import type { JournalContext } from "./tools";
5
5
  import { warmupEmbedder } from "./embeddings";
6
+ import * as fs from "node:fs/promises";
7
+ import * as path from "node:path";
8
+ import { splitFrontmatter } from "./frontmatter";
9
+
10
+ const INDEX_NOTICE = "Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list.";
11
+ const INDEX_WRAPPER = "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.";
12
+ const MAX_INDEX_WORDS = 330;
13
+ const MAX_INDEX_BYTES = 8192;
6
14
 
7
15
  export default Plugin.define({
8
16
  id: "opencode-agent-memory",
@@ -20,6 +28,7 @@ export default Plugin.define({
20
28
  const journalStore = createJournalStore(undefined, config.cacheDir);
21
29
  void warmupEmbedder(config.cacheDir).catch(() => {});
22
30
  const contexts = new Map<string, JournalContext>();
31
+ // Location.Info.directory is the project directory (there is no separate root field).
23
32
  const directory = ctx.location.directory;
24
33
  const tools = [
25
34
  JournalWrite(journalStore, {
@@ -44,12 +53,22 @@ export default Plugin.define({
44
53
  return contextual.execute(input, toolCtx);
45
54
  };
46
55
 
47
- await ctx.session.hook("context", (input) => {
56
+ await ctx.session.hook("context", async (input) => {
48
57
  contexts.set(input.sessionID, {
49
58
  directory,
50
59
  model: input.model.id,
51
60
  provider: input.model.providerID,
52
61
  });
62
+ try {
63
+ const source = await fs.readFile(path.join(directory, "MEMORY.md"), "utf8");
64
+ const body = splitFrontmatter(source).body;
65
+ if (body.trim().length === 0) return;
66
+ const rendered = `${INDEX_WRAPPER}\n\n${body}`;
67
+ const words = body.trim().split(/\s+/).length;
68
+ input.system.push({ type: "text", text: words <= MAX_INDEX_WORDS && Buffer.byteLength(rendered, "utf8") <= MAX_INDEX_BYTES ? rendered : INDEX_NOTICE });
69
+ } catch {
70
+ // Missing and unreadable project indexes are intentionally silent.
71
+ }
53
72
  });
54
73
  await ctx.tool.transform((editor) => {
55
74
  // Transforms may be replayed; avoid duplicate registrations by effective tool name.
@@ -0,0 +1,15 @@
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
+ });