@ghilteras/opencode-agent-memory 0.5.2 → 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 +30 -12
- package/package.json +7 -4
- package/src/doctor.test.ts +183 -0
- package/src/doctor.ts +87 -0
- package/src/journal-doctor.ts +11 -0
- package/src/journal.test.ts +27 -6
- package/src/journal.ts +3 -3
- package/src/plugin-disabled.test.ts +32 -63
- package/src/plugin.test.ts +205 -114
- package/src/plugin.ts +70 -50
- package/src/test-suite.test.ts +15 -0
- package/src/tools.test.ts +97 -235
- package/src/tools.ts +73 -118
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Think of it as a searchable sidecar to `AGENTS.md`. OpenCode supports [rules](ht
|
|
|
24
24
|
|
|
25
25
|
## Requirements
|
|
26
26
|
|
|
27
|
-
- [OpenCode](https://opencode.ai/)
|
|
27
|
+
- [OpenCode](https://opencode.ai/) **v2** — this version uses the OpenCode v2 plugin API and does **not** run on v1
|
|
28
28
|
|
|
29
29
|
## Installation
|
|
30
30
|
|
|
@@ -32,32 +32,33 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
35
|
-
"
|
|
35
|
+
"plugins": ["@ghilteras/opencode-agent-memory@1.1.0"]
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
OpenCode fetches unpinned plugins from npm on each startup; pinned versions are cached and require a manual version bump to update.
|
|
39
|
+
Note OpenCode **v2** reads the `plugins` key (v1 used `plugin`). OpenCode fetches unpinned plugins from npm on each startup; pinned versions are cached and require a manual version bump to update.
|
|
40
40
|
|
|
41
41
|
## Journal
|
|
42
42
|
|
|
43
|
-
The journal is **
|
|
43
|
+
The journal is **on by default** — the three tools are registered with no configuration required.
|
|
44
|
+
|
|
45
|
+
To turn it off, set `enabled: false` in `~/.config/opencode/agent-memory.json`:
|
|
44
46
|
|
|
45
47
|
```json
|
|
46
48
|
{
|
|
47
49
|
"journal": {
|
|
48
|
-
"enabled":
|
|
50
|
+
"enabled": false
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
- A missing, unreadable, malformed, or schema-invalid config logs `[agent-memory] journal config <resolved path>: <reason>; journal tools will NOT be registered` and registers **no tools**.
|
|
55
|
+
- An explicit disable (`"enabled": false`) is silent by design and registers **no tools**.
|
|
56
|
+
- A config that exists but is unreadable, malformed, or schema-invalid **fails closed**: it logs `[agent-memory] journal config <resolved path>: <reason>; journal tools will NOT be registered` and registers **no tools**.
|
|
57
|
+
- An **absent** config means the default: the tools register. (This changed in 1.0.0 — an absent config used to disable the journal.)
|
|
57
58
|
|
|
58
59
|
### Tools
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
The agent gets 3 tools:
|
|
61
62
|
|
|
62
63
|
| Tool | Description |
|
|
63
64
|
|------|-------------|
|
|
@@ -119,6 +120,15 @@ By default the transformers.js model cache lives in the default location (`~/.ca
|
|
|
119
120
|
}
|
|
120
121
|
```
|
|
121
122
|
|
|
123
|
+
## Upgrading to 1.0.0
|
|
124
|
+
|
|
125
|
+
**This release requires OpenCode v2.** The plugin was ported from the v1 plugin API to the v2 plugin API and will not load on v1. The config key is now `plugins` (v1 used `plugin`).
|
|
126
|
+
|
|
127
|
+
Two behaviour changes:
|
|
128
|
+
|
|
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
|
+
- **Storage is unchanged.** Existing entries under `~/.config/opencode/journal/` and their `.embedding` sidecars stay readable; nothing is migrated or rewritten.
|
|
131
|
+
|
|
122
132
|
## Upgrading from 0.4.x
|
|
123
133
|
|
|
124
134
|
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.
|
|
@@ -127,9 +137,9 @@ Existing `~/.config/opencode/memory/` and `.opencode/memory/` directories are **
|
|
|
127
137
|
|
|
128
138
|
## Compatibility & Troubleshooting
|
|
129
139
|
|
|
130
|
-
- **Requires OpenCode v1.
|
|
140
|
+
- **Requires OpenCode v2.** This release uses the OpenCode v2 plugin API and does not run on v1.
|
|
131
141
|
- **Restart after config changes.** Plugin config changes require a full OpenCode restart to take effect; editing the config file alone is not sufficient.
|
|
132
|
-
- **Journal is
|
|
142
|
+
- **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).
|
|
133
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.
|
|
134
144
|
- **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.
|
|
135
145
|
|
|
@@ -146,3 +156,11 @@ Originally created by Joshua David Thomas and licensed under the MIT license. Ma
|
|
|
146
156
|
opencode-agent-memory is not built by, or affiliated with, the OpenCode team.
|
|
147
157
|
|
|
148
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": "
|
|
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,12 +17,15 @@
|
|
|
17
17
|
"src"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "bun test",
|
|
21
|
-
"
|
|
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
|
-
"@opencode
|
|
28
|
+
"@opencode/plugin": "^2.0.18",
|
|
26
29
|
"js-yaml": "^4.1.0",
|
|
27
30
|
"zod": "^4.1.13"
|
|
28
31
|
},
|
|
@@ -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
|
+
}
|
package/src/journal.test.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
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();
|
|
@@ -75,6 +87,14 @@ describe("loadConfig", () => {
|
|
|
75
87
|
expect(loaded.config).toEqual({});
|
|
76
88
|
});
|
|
77
89
|
|
|
90
|
+
test("rejects an unrecognized journal key instead of silently enabling", async () => {
|
|
91
|
+
const dir = await mkTmpDir();
|
|
92
|
+
await fs.writeFile(path.join(dir, "agent-memory.json"), JSON.stringify({ journal: { enabled_: false } }));
|
|
93
|
+
const loaded = await loadConfig(dir);
|
|
94
|
+
expect(loaded.status).toBe("invalid");
|
|
95
|
+
expect(loaded.config).toEqual({});
|
|
96
|
+
});
|
|
97
|
+
|
|
78
98
|
test("classifies valid disabled config without changing it", async () => {
|
|
79
99
|
const dir = await mkTmpDir();
|
|
80
100
|
await fs.writeFile(
|
|
@@ -513,8 +533,9 @@ describe("journal store", () => {
|
|
|
513
533
|
body: "unique-sidecar-deleted-content",
|
|
514
534
|
});
|
|
515
535
|
|
|
516
|
-
//
|
|
517
|
-
|
|
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({});
|
|
518
539
|
expect(r1.entries.some((e) => e.title === "Sidecar deleted entry")).toBe(true);
|
|
519
540
|
|
|
520
541
|
// Delete the sidecar file.
|
package/src/journal.ts
CHANGED
|
@@ -16,10 +16,10 @@ const TagSchema = z.looseObject({
|
|
|
16
16
|
const ConfigSchema = z.looseObject({
|
|
17
17
|
cacheDir: z.string().optional(),
|
|
18
18
|
journal: z
|
|
19
|
-
.
|
|
19
|
+
.object({
|
|
20
20
|
enabled: z.boolean().optional(),
|
|
21
21
|
tags: z.array(TagSchema).optional(),
|
|
22
|
-
})
|
|
22
|
+
}).strict()
|
|
23
23
|
.optional(),
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -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,70 +1,39 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
configPath: "/tmp/test-plugin-disabled/agent-memory.json",
|
|
9
|
-
}),
|
|
10
|
-
createJournalStore: () => ({}),
|
|
11
|
-
}));
|
|
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");
|
|
12
11
|
|
|
13
|
-
// Mock embeddings to avoid model download
|
|
14
12
|
mock.module("./embeddings", () => ({
|
|
13
|
+
...realEmbeddings,
|
|
14
|
+
generateEmbedding: async () => Array.from({ length: 384 }, (_, i) => Math.sin(i) * 0.5),
|
|
15
15
|
warmupEmbedder: async () => {},
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// any description with journal guidance keywords
|
|
40
|
-
if (plugin.tool) {
|
|
41
|
-
for (const def of Object.values(plugin.tool)) {
|
|
42
|
-
const desc = (def as { description?: string }).description ?? "";
|
|
43
|
-
expect(desc).not.toMatch(/append-only|journal|search.*entry/i);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("chat.message hook is still present when disabled", async () => {
|
|
49
|
-
const plugin = await MemoryPlugin(stubInput);
|
|
50
|
-
// The chat.message hook updates model/provider context — it should
|
|
51
|
-
// still be present even when journal is disabled.
|
|
52
|
-
expect(plugin["chat.message"]).toBeDefined();
|
|
53
|
-
expect(typeof plugin["chat.message"]).toBe("function");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("does not warn for an intentional disabled configuration", async () => {
|
|
57
|
-
const originalWarn = console.warn;
|
|
58
|
-
let warningCount = 0;
|
|
59
|
-
console.warn = () => {
|
|
60
|
-
warningCount += 1;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
await MemoryPlugin(stubInput);
|
|
65
|
-
expect(warningCount).toBe(0);
|
|
66
|
-
} finally {
|
|
67
|
-
console.warn = originalWarn;
|
|
68
|
-
}
|
|
69
|
-
});
|
|
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
|
+
}
|
|
70
39
|
});
|