@ghilteras/opencode-agent-memory 0.5.1 → 0.5.2

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
- "plugin": ["@ghilteras/opencode-agent-memory@0.5.0"]
35
+ "plugin": ["@ghilteras/opencode-agent-memory@0.5.2"]
36
36
  }
37
37
  ```
38
38
 
@@ -50,7 +50,10 @@ The journal is **opt-in**. Enable it in `~/.config/opencode/agent-memory.json`:
50
50
  }
51
51
  ```
52
52
 
53
- With the journal disabled the plugin registers **no tools**.
53
+ The plugin does **not** regenerate `agent-memory.json` if it is deleted; its loss otherwise silently disables the journal, which is why the fault warning exists.
54
+
55
+ - Intentional disable (`"enabled": false`) is silent by design and registers **no tools**.
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**.
54
57
 
55
58
  ### Tools
56
59
 
@@ -126,9 +129,9 @@ Existing `~/.config/opencode/memory/` and `.opencode/memory/` directories are **
126
129
 
127
130
  - **Requires OpenCode v1.0.115+.**
128
131
  - **Restart after config changes.** Plugin config changes require a full OpenCode restart to take effect; editing the config file alone is not sufficient.
129
- - **Journal is opt-in.** You must explicitly enable it in `~/.config/opencode/agent-memory.json`; with it disabled the plugin registers no tools.
132
+ - **Journal is opt-in.** You must explicitly enable it in `~/.config/opencode/agent-memory.json`; intentional disablement is silent, while a missing, unreadable, malformed, or schema-invalid config logs a warning and registers no tools.
130
133
  - **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.
131
- - **Known limitation (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.
134
+ - **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.
132
135
 
133
136
  ## Inspiration
134
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghilteras/opencode-agent-memory",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",
@@ -41,27 +41,60 @@ async function mkTmpDir(): Promise<string> {
41
41
  }
42
42
 
43
43
  describe("loadConfig", () => {
44
- test("returns empty config when file does not exist", async () => {
44
+ test("classifies a missing file and returns an empty config", async () => {
45
45
  const dir = await mkTmpDir();
46
- const config = await loadConfig(dir);
47
- expect(config).toEqual({});
46
+ const loaded = await loadConfig(dir);
47
+ expect(loaded.status).toBe("missing");
48
+ expect(loaded.config).toEqual({});
48
49
  });
49
50
 
50
- test("returns parsed config when file is valid", async () => {
51
+ test("classifies an unreadable file and returns an empty config", async () => {
52
+ const dir = await mkTmpDir();
53
+ await fs.mkdir(path.join(dir, "agent-memory.json"));
54
+ const loaded = await loadConfig(dir);
55
+ expect(loaded.status).toBe("unreadable");
56
+ expect(loaded.config).toEqual({});
57
+ });
58
+
59
+ test("classifies malformed JSON and returns an empty config", async () => {
60
+ const dir = await mkTmpDir();
61
+ await fs.writeFile(path.join(dir, "agent-memory.json"), "not json{{{");
62
+ const loaded = await loadConfig(dir);
63
+ expect(loaded.status).toBe("malformed");
64
+ expect(loaded.config).toEqual({});
65
+ });
66
+
67
+ test("classifies schema-invalid JSON and returns an empty config", async () => {
51
68
  const dir = await mkTmpDir();
52
69
  await fs.writeFile(
53
70
  path.join(dir, "agent-memory.json"),
54
- JSON.stringify({ journal: { enabled: true } }),
71
+ JSON.stringify({ journal: { enabled: "yes" } }),
55
72
  );
56
- const config = await loadConfig(dir);
57
- expect(config.journal?.enabled).toBe(true);
73
+ const loaded = await loadConfig(dir);
74
+ expect(loaded.status).toBe("invalid");
75
+ expect(loaded.config).toEqual({});
58
76
  });
59
77
 
60
- test("returns empty config when file has invalid JSON", async () => {
78
+ test("classifies valid disabled config without changing it", async () => {
61
79
  const dir = await mkTmpDir();
62
- await fs.writeFile(path.join(dir, "agent-memory.json"), "not json{{{");
63
- const config = await loadConfig(dir);
64
- expect(config).toEqual({});
80
+ await fs.writeFile(
81
+ path.join(dir, "agent-memory.json"),
82
+ JSON.stringify({ journal: { enabled: false } }),
83
+ );
84
+ const loaded = await loadConfig(dir);
85
+ expect(loaded.status).toBe("ok");
86
+ expect(loaded.config).toEqual({ journal: { enabled: false } });
87
+ });
88
+
89
+ test("classifies valid enabled config without changing it", async () => {
90
+ const dir = await mkTmpDir();
91
+ await fs.writeFile(
92
+ path.join(dir, "agent-memory.json"),
93
+ JSON.stringify({ journal: { enabled: true } }),
94
+ );
95
+ const loaded = await loadConfig(dir);
96
+ expect(loaded.status).toBe("ok");
97
+ expect(loaded.config).toEqual({ journal: { enabled: true } });
65
98
  });
66
99
 
67
100
  test("returns custom tags from config", async () => {
@@ -78,22 +111,12 @@ describe("loadConfig", () => {
78
111
  },
79
112
  }),
80
113
  );
81
- const config = await loadConfig(dir);
82
- expect(config.journal?.tags).toEqual([
114
+ const loaded = await loadConfig(dir);
115
+ expect(loaded.config.journal?.tags).toEqual([
83
116
  { name: "perf", description: "Performance optimization work" },
84
117
  { name: "debug", description: "Debugging sessions" },
85
118
  ]);
86
119
  });
87
-
88
- test("returns empty config when schema validation fails", async () => {
89
- const dir = await mkTmpDir();
90
- await fs.writeFile(
91
- path.join(dir, "agent-memory.json"),
92
- JSON.stringify({ journal: { enabled: "yes" } }),
93
- );
94
- const config = await loadConfig(dir);
95
- expect(config).toEqual({});
96
- });
97
120
  });
98
121
 
99
122
  describe("journal store", () => {
package/src/journal.ts CHANGED
@@ -25,19 +25,72 @@ const ConfigSchema = z.looseObject({
25
25
 
26
26
  export type AgentMemoryConfig = z.infer<typeof ConfigSchema>;
27
27
 
28
+ export type ConfigLoadStatus =
29
+ | "ok"
30
+ | "missing"
31
+ | "unreadable"
32
+ | "malformed"
33
+ | "invalid";
34
+
35
+ export type LoadedConfig = {
36
+ config: AgentMemoryConfig;
37
+ status: ConfigLoadStatus;
38
+ configPath: string;
39
+ reason?: string;
40
+ };
41
+
28
42
  export async function loadConfig(
29
43
  configDir?: string,
30
- ): Promise<AgentMemoryConfig> {
44
+ ): Promise<LoadedConfig> {
31
45
  const dir = configDir ?? path.join(os.homedir(), ".config", "opencode");
32
- const configPath = path.join(dir, "agent-memory.json");
46
+ const configPath = path.resolve(dir, "agent-memory.json");
47
+
48
+ let raw: string;
49
+ try {
50
+ raw = await fs.readFile(configPath, "utf-8");
51
+ } catch (error) {
52
+ const code = error instanceof Error && "code" in error
53
+ ? String(error.code)
54
+ : "unknown error";
55
+ if (code === "ENOENT") {
56
+ return {
57
+ config: {},
58
+ status: "missing",
59
+ configPath,
60
+ reason: "file is missing",
61
+ };
62
+ }
63
+ return {
64
+ config: {},
65
+ status: "unreadable",
66
+ configPath,
67
+ reason: `read failed (${code})`,
68
+ };
69
+ }
70
+
71
+ let value: unknown;
33
72
  try {
34
- const raw = await fs.readFile(configPath, "utf-8");
35
- const parsed = ConfigSchema.safeParse(JSON.parse(raw));
36
- if (!parsed.success) return {};
37
- return parsed.data;
73
+ value = JSON.parse(raw);
38
74
  } catch {
39
- return {};
75
+ return {
76
+ config: {},
77
+ status: "malformed",
78
+ configPath,
79
+ reason: "malformed JSON",
80
+ };
40
81
  }
82
+
83
+ const parsed = ConfigSchema.safeParse(value);
84
+ if (!parsed.success) {
85
+ return {
86
+ config: {},
87
+ status: "invalid",
88
+ configPath,
89
+ reason: "schema validation failed",
90
+ };
91
+ }
92
+
93
+ return { config: parsed.data, status: "ok", configPath };
41
94
  }
42
95
 
43
96
  export type JournalTag = {
@@ -451,4 +504,3 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
451
504
  },
452
505
  };
453
506
  }
454
-
@@ -3,7 +3,9 @@ import { describe, expect, mock, test } from "bun:test";
3
3
  // Mock journal module with journal DISABLED — the control path.
4
4
  mock.module("./journal", () => ({
5
5
  loadConfig: async () => ({
6
- journal: { enabled: false },
6
+ config: { journal: { enabled: false } },
7
+ status: "ok",
8
+ configPath: "/tmp/test-plugin-disabled/agent-memory.json",
7
9
  }),
8
10
  createJournalStore: () => ({}),
9
11
  }));
@@ -50,4 +52,19 @@ describe("plugin disabled path", () => {
50
52
  expect(plugin["chat.message"]).toBeDefined();
51
53
  expect(typeof plugin["chat.message"]).toBe("function");
52
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
+ });
53
70
  });
@@ -1,10 +1,15 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
+ import type { LoadedConfig } from "./journal";
3
+
4
+ let loadedConfig: LoadedConfig = {
5
+ config: { journal: { enabled: true } },
6
+ status: "ok" as const,
7
+ configPath: "/tmp/test-plugin/agent-memory.json",
8
+ };
2
9
 
3
10
  // Mock journal module so we don't hit the real filesystem for config/journal
4
11
  mock.module("./journal", () => ({
5
- loadConfig: async () => ({
6
- journal: { enabled: true },
7
- }),
12
+ loadConfig: async () => loadedConfig,
8
13
  createJournalStore: () => ({}),
9
14
  }));
10
15
 
@@ -19,6 +24,49 @@ import type { PluginInput } from "@opencode-ai/plugin";
19
24
  // Minimal stub: the plugin only reads `directory` from the input.
20
25
  const stubInput = { directory: "/tmp/test-plugin-regression" } as unknown as PluginInput;
21
26
 
27
+ type FaultStatus = Exclude<LoadedConfig["status"], "ok">;
28
+
29
+ const faultCases: Array<{
30
+ status: FaultStatus;
31
+ reason: string;
32
+ config: LoadedConfig["config"];
33
+ }> = [
34
+ { status: "missing", reason: "file is missing", config: {} },
35
+ { status: "unreadable", reason: "read failed (EACCES)", config: {} },
36
+ { status: "malformed", reason: "malformed JSON", config: {} },
37
+ {
38
+ // A non-ok status must win even if the mocked config is truthy.
39
+ status: "invalid",
40
+ reason: "schema validation failed",
41
+ config: { journal: { enabled: true } },
42
+ },
43
+ ];
44
+
45
+ const okCases: Array<{
46
+ name: string;
47
+ config: LoadedConfig["config"];
48
+ expectedToolCount: number;
49
+ }> = [
50
+ {
51
+ name: "enabled",
52
+ config: { journal: { enabled: true } },
53
+ expectedToolCount: 3,
54
+ },
55
+ {
56
+ name: "disabled",
57
+ config: { journal: { enabled: false } },
58
+ expectedToolCount: 0,
59
+ },
60
+ ];
61
+
62
+ function resetLoadedConfig(): void {
63
+ loadedConfig = {
64
+ config: { journal: { enabled: true } },
65
+ status: "ok",
66
+ configPath: "/tmp/test-plugin/agent-memory.json",
67
+ };
68
+ }
69
+
22
70
  describe("plugin structure", () => {
23
71
  test("returns chat.message hook", async () => {
24
72
  const plugin = await MemoryPlugin(stubInput);
@@ -31,11 +79,51 @@ describe("plugin structure", () => {
31
79
  expect(plugin["experimental.chat.system.transform"]).toBeUndefined();
32
80
  });
33
81
 
34
- test("registers journal tools when enabled", async () => {
35
- const plugin = await MemoryPlugin(stubInput);
36
- expect(plugin.tool).toBeDefined();
37
- expect(plugin.tool!["journal_write"]).toBeDefined();
38
- expect(plugin.tool!["journal_read"]).toBeDefined();
39
- expect(plugin.tool!["journal_search"]).toBeDefined();
40
- });
82
+ for (const testCase of faultCases) {
83
+ test(`warns and disables tools for ${testCase.status} config`, async () => {
84
+ loadedConfig = {
85
+ config: testCase.config,
86
+ status: testCase.status,
87
+ configPath: "/tmp/test-plugin/agent-memory.json",
88
+ reason: testCase.reason,
89
+ };
90
+ const originalWarn = console.warn;
91
+ const warnings: unknown[][] = [];
92
+ console.warn = (...args: unknown[]) => warnings.push(args);
93
+
94
+ try {
95
+ const plugin = await MemoryPlugin(stubInput);
96
+ expect(Object.keys(plugin.tool ?? {})).toHaveLength(0);
97
+ expect(warnings).toHaveLength(1);
98
+ expect(warnings[0]?.[0]).toBe(
99
+ `[agent-memory] journal config /tmp/test-plugin/agent-memory.json: ${testCase.reason}; journal tools will NOT be registered`,
100
+ );
101
+ } finally {
102
+ console.warn = originalWarn;
103
+ resetLoadedConfig();
104
+ }
105
+ });
106
+ }
107
+
108
+ for (const testCase of okCases) {
109
+ test(`does not warn for valid ${testCase.name} config`, async () => {
110
+ loadedConfig = {
111
+ config: testCase.config,
112
+ status: "ok",
113
+ configPath: "/tmp/test-plugin/agent-memory.json",
114
+ };
115
+ const originalWarn = console.warn;
116
+ const warnings: unknown[][] = [];
117
+ console.warn = (...args: unknown[]) => warnings.push(args);
118
+
119
+ try {
120
+ const plugin = await MemoryPlugin(stubInput);
121
+ expect(Object.keys(plugin.tool ?? {})).toHaveLength(testCase.expectedToolCount);
122
+ expect(warnings).toHaveLength(0);
123
+ } finally {
124
+ console.warn = originalWarn;
125
+ resetLoadedConfig();
126
+ }
127
+ });
128
+ }
41
129
  });
package/src/plugin.ts CHANGED
@@ -14,8 +14,16 @@ import { warmupEmbedder } from "./embeddings";
14
14
 
15
15
  export const MemoryPlugin: Plugin = async ({ directory }) => {
16
16
  // Journal: opt-in via ~/.config/opencode/agent-memory.json
17
- const config = await loadConfig();
18
- const journalEnabled = config.journal?.enabled === true;
17
+ const loadedConfig = await loadConfig();
18
+ const { config } = loadedConfig;
19
+
20
+ if (loadedConfig.status !== "ok") {
21
+ console.warn(
22
+ `[agent-memory] journal config ${loadedConfig.configPath}: ${loadedConfig.reason ?? loadedConfig.status}; journal tools will NOT be registered`,
23
+ );
24
+ }
25
+
26
+ const journalEnabled = loadedConfig.status === "ok" && config.journal?.enabled === true;
19
27
 
20
28
  // Mutable state updated by chat.message hook
21
29
  const journalCtx: JournalContext = {
package/src/tools.test.ts CHANGED
@@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import { JournalRead, JournalSearch, JournalWrite } from "./tools";
4
4
  import type { JournalContext } from "./tools";
5
- import type { JournalStore, JournalTag } from "./journal";
5
+ import type { JournalEntry, JournalStore } from "./journal";
6
6
 
7
- // Minimal mock store — tests only check description strings, not execute.
7
+ // Minimal mock store — description tests use the default stubs, while search
8
+ // output tests replace search with deterministic fixtures.
8
9
  const mockStore: JournalStore = {
9
10
  write: async () => {
10
11
  throw new Error("not implemented");
@@ -38,6 +39,31 @@ const READ_DESC = JournalRead(mockStore).description;
38
39
  const SEARCH_TOOL = JournalSearch(mockStore);
39
40
  const SEARCH_DESC = SEARCH_TOOL.description;
40
41
 
42
+ function makeEntry(tags: string[]): JournalEntry {
43
+ return {
44
+ id: "20260101-000000-000",
45
+ title: "Retrieved entry",
46
+ project: "/tmp/test",
47
+ model: "test-model",
48
+ provider: "test-provider",
49
+ agent: "test-agent",
50
+ sessionId: "test-session",
51
+ created: new Date("2026-01-01T00:00:00.000Z"),
52
+ tags,
53
+ body: "Entry body",
54
+ filePath: "/tmp/test-entry.md",
55
+ };
56
+ }
57
+
58
+ async function executeSearch(result: Awaited<ReturnType<JournalStore["search"]>>): Promise<string> {
59
+ const store: JournalStore = {
60
+ ...mockStore,
61
+ search: async () => result,
62
+ };
63
+ const output = await JournalSearch(store).execute!({}, {} as never);
64
+ return String(output);
65
+ }
66
+
41
67
  describe("journal_write description", () => {
42
68
  test("mentions append-only semantics", () => {
43
69
  expect(WRITE_DESC_NO_TAGS).toContain("append-only");
@@ -130,6 +156,66 @@ describe("journal_search description", () => {
130
156
  });
131
157
  });
132
158
 
159
+ describe("journal_search tag inventory output", () => {
160
+ const manyTags = Array.from({ length: 21 }, (_, index) => `tag-${index + 1}`);
161
+ const exactlyTwentyTags = Array.from({ length: 20 }, (_, index) => `tag-${index + 1}`);
162
+
163
+ test("bounds large tag inventories while reporting their count", async () => {
164
+ const output = await executeSearch({
165
+ entries: [makeEntry(["entry-tag"])],
166
+ total: 1,
167
+ allTags: manyTags,
168
+ });
169
+
170
+ expect(output.split("\n")[1]).toBe("Tags in use: 21 (filter with tags=...)");
171
+ expect(output).not.toContain("tag-1");
172
+ expect(output).not.toContain("tag-21");
173
+ });
174
+
175
+ test("lists small tag inventories", async () => {
176
+ const output = await executeSearch({
177
+ entries: [makeEntry(["entry-tag"])],
178
+ total: 1,
179
+ allTags: ["perf", "debug"],
180
+ });
181
+
182
+ expect(output).toContain("Tags in use: perf, debug");
183
+ });
184
+
185
+ test("lists exactly 20 tags at the boundary", async () => {
186
+ const output = await executeSearch({
187
+ entries: [makeEntry(["entry-tag"])],
188
+ total: 1,
189
+ allTags: exactlyTwentyTags,
190
+ });
191
+
192
+ expect(output.split("\n")[1]).toBe(`Tags in use: ${exactlyTwentyTags.join(", ")}`);
193
+ });
194
+
195
+ test("omits the tag inventory line when no tags exist", async () => {
196
+ const output = await executeSearch({
197
+ entries: [],
198
+ total: 0,
199
+ allTags: [],
200
+ });
201
+
202
+ expect(output).toBe("No journal entries found.");
203
+ });
204
+
205
+ test("bounds large tag inventories for empty results too", async () => {
206
+ const output = await executeSearch({
207
+ entries: [],
208
+ total: 0,
209
+ allTags: manyTags,
210
+ });
211
+
212
+ expect(output).toContain("No journal entries found.");
213
+ expect(output.split("\n")[1]).toBe("Tags in use: 21 (filter with tags=...)");
214
+ expect(output).not.toContain("tag-1");
215
+ expect(output).not.toContain("tag-21");
216
+ });
217
+ });
218
+
133
219
  // ─── Cross-tool invariants ───────────────────────────────────────────────────
134
220
 
135
221
  describe("cross-tool invariants", () => {
package/src/tools.ts CHANGED
@@ -8,6 +8,21 @@ export type JournalContext = {
8
8
  provider: string;
9
9
  };
10
10
 
11
+ const MAX_TAGS_TO_LIST = 20;
12
+
13
+ function formatTagsInUse(allTags: readonly string[]): string {
14
+ if (allTags.length === 0) {
15
+ return "";
16
+ }
17
+
18
+ // Retrieved entries already show their own tags, so avoid repeating the
19
+ // entire global inventory when it grows beyond a useful size.
20
+ if (allTags.length > MAX_TAGS_TO_LIST) {
21
+ return `\nTags in use: ${allTags.length} (filter with tags=...)`;
22
+ }
23
+ return `\nTags in use: ${allTags.join(", ")}`;
24
+ }
25
+
11
26
  export function JournalWrite(
12
27
  store: JournalStore,
13
28
  ctx: JournalContext,
@@ -115,20 +130,13 @@ export function JournalSearch(store: JournalStore) {
115
130
  offset: args.offset,
116
131
  });
117
132
 
133
+ const tagsLine = formatTagsInUse(result.allTags);
118
134
  if (result.entries.length === 0) {
119
- const tagsLine =
120
- result.allTags.length > 0
121
- ? `\nTags in use: ${result.allTags.join(", ")}`
122
- : "";
123
135
  return `No journal entries found.${tagsLine}`;
124
136
  }
125
137
 
126
138
  const offset = args.offset ?? 0;
127
139
  const header = `Found ${result.total} entries (showing ${offset + 1}–${offset + result.entries.length}):`;
128
- const tagsLine =
129
- result.allTags.length > 0
130
- ? `\nTags in use: ${result.allTags.join(", ")}`
131
- : "";
132
140
 
133
141
  const lines = result.entries.map((e) => {
134
142
  const tagStr =