@ghilteras/opencode-agent-memory 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  >
7
7
  > Originally created by Joshua David Thomas and licensed under the MIT license. Attribution preserved below; see License section.
8
8
 
9
- A journal-only memory plugin for [OpenCode](https://opencode.ai): an append-only, tagged journal with local semantic search.
9
+ A journal-only memory plugin for [OpenCode](https://opencode.ai): an append-only, tagged journal with local lexical search.
10
10
 
11
11
  ## Experimental
12
12
 
@@ -17,7 +17,7 @@ Think of it as a searchable sidecar to `AGENTS.md`. OpenCode supports [rules](ht
17
17
  ## Features
18
18
 
19
19
  - **Append-only journal** - Entries survive across sessions and context compaction
20
- - **Local semantic search** - Find entries by meaning, not just keywords
20
+ - **Local lexical search** - Find candidate entries by matching query words in titles and bodies
21
21
  - **Metadata on every entry** - Project, model, provider, agent, session, timestamp, tags
22
22
  - **Guidance in tool descriptions** - Journal usage guidance lives in the descriptions of the journal tools, so an agent sees it only when it is allowed to call them
23
23
  - **No data leaves the machine** - Embeddings run locally
@@ -32,7 +32,7 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
32
32
 
33
33
  ```json
34
34
  {
35
- "plugins": ["@ghilteras/opencode-agent-memory@1.0.0"]
35
+ "plugins": ["@ghilteras/opencode-agent-memory@2.0.0"]
36
36
  }
37
37
  ```
38
38
 
@@ -63,7 +63,7 @@ The agent gets 3 tools:
63
63
  | Tool | Description |
64
64
  |------|-------------|
65
65
  | `journal_write` | Write a new append-only entry (title, body, optional tags) |
66
- | `journal_search` | Search entries semantically, filter by project or tags, with pagination |
66
+ | `journal_search` | Find lexical candidates by matching query words in titles and bodies; filter by project or tags, with pagination |
67
67
  | `journal_read` | Read a specific journal entry by ID |
68
68
 
69
69
  ### Entry format
@@ -85,9 +85,9 @@ tags: [plugin, wiring]
85
85
  Body text...
86
86
  ```
87
87
 
88
- ### Semantic search
88
+ ### Text search
89
89
 
90
- Search uses local embeddings ([paraphrase-multilingual-MiniLM-L12-v2](https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2), 384d, multilingual EN/IT) - no data leaves your machine. An `.embedding` sidecar is written next to each entry in a versioned format (`{ v: 2, model, dimension, vector }`). The search gate is a **dimension** check: an entry whose recorded `dimension` differs from the plugin's expected dimension is not cosine-compared - it falls back to text matching instead of failing the search. Legacy bare-array embeddings from v0.3.x remain readable.
90
+ Text search runs one MiniSearch BM25+ pass over title and body. Query terms are OR-matched, with native score order and a deterministic tie-break by creation time and ID. This is keyword matching, not a claim of semantic relevance: results are candidates for the agent to inspect. Matching works across languages by matching literal words, not by meaning. When a query has no tokenizable terms (for example, `!!!`), search falls back to case-insensitive exact-substring matching over title and body. The retained index is reused while the ordered candidate-entry ID list is unchanged, avoiding repeated index construction. Embedding sidecars are still written in the versioned `{ v: 2, model, dimension, vector }` format for compatibility, but search does not read them.
91
91
 
92
92
  ### Suggested tags
93
93
 
@@ -129,6 +129,12 @@ Two behaviour changes:
129
129
  - **The journal tools are registered by default.** Previously you had to opt in through `agent-memory.json`; now an absent config means the tools are on, and you turn them off with `"enabled": false`.
130
130
  - **Storage is unchanged.** Existing entries under `~/.config/opencode/journal/` and their `.embedding` sidecars stay readable; nothing is migrated or rewritten.
131
131
 
132
+ ## Upgrading to lexical candidate search
133
+
134
+ - Text search now ranks entries with lexical OR matching: a candidate may match any query term in its title or body.
135
+ - Results are candidates, not relevance claims. `Found N` reports the candidate count before pagination and will usually be large.
136
+ - A punctuation-only query falls back to case-insensitive substring matching over title and body.
137
+
132
138
  ## Upgrading from 0.4.x
133
139
 
134
140
  Memory blocks and their tools (`memory_list`, `memory_set`, `memory_replace`) are **removed**. Blocks are no longer read, written, or injected into the system prompt; the plugin ships only the journal.
@@ -140,7 +146,7 @@ Existing `~/.config/opencode/memory/` and `.opencode/memory/` directories are **
140
146
  - **Requires OpenCode v2.** This release uses the OpenCode v2 plugin API and does not run on v1.
141
147
  - **Restart after config changes.** Plugin config changes require a full OpenCode restart to take effect; editing the config file alone is not sufficient.
142
148
  - **Journal is on by default.** Turn it off with `"enabled": false`; an explicit disable is silent, while a config that exists but is unreadable, malformed, or schema-invalid logs a warning and registers no tools (fail-closed).
143
- - **Local embeddings, no data leaves the machine.** Semantic search uses a locally cached transformers.js model; no external API calls are made for search or embedding.
149
+ - **Embedding sidecars remain compatible.** They are written locally for the existing format contract, diagnostics, and future consumers; text search is lexical and does not read embeddings.
144
150
  - **Known limitation (since 0.5.1): guidance-in-descriptions depends on an unverified premise.** 0.5.1 moved journal guidance from the injected system-prompt note into the three journal tool descriptions, so only agents allowed to call those tools should see it. That relies on opencode omitting a permission-denied tool from the definitions sent to the model. This is **unverified**: a `tool.definition` probe on the isolated instance fires before permission filtering, so it cannot confirm what the model actually receives. If the premise does not hold, an agent denied `journal_*` may still read the description text; `deny` still blocks execution, so there is no capability escalation and no data disclosure — the blast radius is the three tools' description strings.
145
151
 
146
152
  ## Inspiration
@@ -156,3 +162,11 @@ Originally created by Joshua David Thomas and licensed under the MIT license. Ma
156
162
  opencode-agent-memory is not built by, or affiliated with, the OpenCode team.
157
163
 
158
164
  OpenCode is ©2025 Anomaly.
165
+ # Journal diagnostics
166
+
167
+ Run `bun run journal:doctor [config-dir]` to inspect flat journal Markdown files
168
+ and their `.embedding` sidecars without repairing or changing them. The optional
169
+ directory is the OpenCode state/config directory (the journal is read from its
170
+ `journal/` subdirectory); without it, the default is `~/.config/opencode`.
171
+ The command prints a deterministic JSON report and exits 0 for a complete scan
172
+ 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": "2.0.0",
4
4
  "description": "Journal-only memory plugin for OpenCode: tagged, searchable, append-only journal with semantic search.",
5
5
  "author": "Angelo Pantano <ghilteras@protonmail.com>",
6
6
  "license": "MIT",
@@ -17,13 +17,17 @@
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",
25
28
  "@opencode/plugin": "^2.0.18",
26
29
  "js-yaml": "^4.1.0",
30
+ "minisearch": "^7.2.0",
27
31
  "zod": "^4.1.13"
28
32
  },
29
33
  "overrides": {
@@ -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
+ }