@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.
@@ -1,129 +1,220 @@
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
- };
9
-
10
- // Mock journal module so we don't hit the real filesystem for config/journal
11
- mock.module("./journal", () => ({
12
- loadConfig: async () => loadedConfig,
13
- createJournalStore: () => ({}),
14
- }));
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import * as realEmbeddings from "./embeddings";
6
+
7
+ // These tests run in their own bun process (package.json "test:plugin") with
8
+ // HOME pointed at a throwaway fixture directory, so the real loadConfig and
9
+ // createJournalStore exercise real fixture files. Refuse to run against the
10
+ // real home directory. Only the model-loading surface of ./embeddings is
11
+ // stubbed, spreading the real module so other exports keep real values.
12
+ if (process.env.HOME === undefined || !process.env.HOME.startsWith("/tmp/")) {
13
+ throw new Error("plugin tests require HOME pointed at a /tmp fixture directory; run via `bun run test:plugin`");
14
+ }
15
+
16
+ const fixtureHome = process.env.HOME;
17
+ const configDir = path.join(fixtureHome, ".config", "opencode");
18
+ const configPath = path.join(configDir, "agent-memory.json");
19
+ const journalDir = path.join(configDir, "journal");
15
20
 
16
- // Mock embeddings to avoid model download
17
21
  mock.module("./embeddings", () => ({
22
+ ...realEmbeddings,
23
+ generateEmbedding: async () => Array.from({ length: 384 }, (_, i) => Math.sin(i) * 0.5),
18
24
  warmupEmbedder: async () => {},
19
25
  }));
20
26
 
21
- import { MemoryPlugin } from "./plugin";
22
- import type { PluginInput } from "@opencode-ai/plugin";
23
-
24
- // Minimal stub: the plugin only reads `directory` from the input.
25
- const stubInput = { directory: "/tmp/test-plugin-regression" } as unknown as PluginInput;
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
- };
27
+ import plugin from "./plugin";
28
+
29
+ // value === undefined creates a DIRECTORY at the config path (unreadable);
30
+ // a string is written raw (malformed JSON); an object is JSON-encoded.
31
+ async function writeConfig(value: unknown): Promise<void> {
32
+ await fs.mkdir(configDir, { recursive: true });
33
+ if (value === undefined) {
34
+ await fs.mkdir(configPath, { recursive: true });
35
+ } else if (typeof value === "string") {
36
+ await fs.writeFile(configPath, value, "utf-8");
37
+ } else {
38
+ await fs.writeFile(configPath, JSON.stringify(value), "utf-8");
39
+ }
40
+ }
41
+
42
+ function harness(directory = "/tmp/project") {
43
+ const definitions = new Map<string, any>();
44
+ let contextHook: ((event: any) => void) | undefined;
45
+ let transform: ((editor: { add: (definition: any) => void; list: () => any[] }) => void) | undefined;
46
+ const context = {
47
+ location: { directory },
48
+ session: { hook: async (name: string, callback: (event: any) => void) => { expect(name).toBe("context"); contextHook = callback; } },
49
+ tool: { transform: async (callback: typeof transform) => { transform = callback; callback!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }); } },
50
+ } as any;
51
+ return { definitions, contextHook: () => contextHook, replay: () => transform!({ add: (definition) => definitions.set(definition.name, definition), list: () => [...definitions.values()] }), start: () => plugin.setup(context) };
68
52
  }
69
53
 
70
- describe("plugin structure", () => {
71
- test("returns chat.message hook", async () => {
72
- const plugin = await MemoryPlugin(stubInput);
73
- expect(plugin["chat.message"]).toBeDefined();
74
- expect(typeof plugin["chat.message"]).toBe("function");
54
+ afterEach(async () => {
55
+ await fs.rm(configDir, { recursive: true, force: true });
56
+ });
57
+
58
+ describe("v2 plugin registration", () => {
59
+ test("has a stable id and default-registers exactly the three journal tools", async () => {
60
+ expect(plugin.id).toBe("opencode-agent-memory");
61
+ await fs.rm(configDir, { recursive: true, force: true }); // absent config: default-on journal tools
62
+ const h = harness();
63
+ await h.start();
64
+ expect([...h.definitions.keys()].sort()).toEqual(["journal_read", "journal_search", "journal_write"]);
75
65
  });
76
66
 
77
- test("does NOT return experimental.chat.system.transform hook", async () => {
78
- const plugin = await MemoryPlugin(stubInput);
79
- expect(plugin["experimental.chat.system.transform"]).toBeUndefined();
67
+ test("registered write description suggests configured tag names without their descriptions", async () => {
68
+ await writeConfig({ journal: { tags: [
69
+ { name: "owner_approved", description: "Use for a choice made and its rationale." },
70
+ { name: "incident_followup", description: "Use for investigation notes and findings." },
71
+ ] } });
72
+ const h = harness();
73
+ await h.start();
74
+ const description = h.definitions.get("journal_write").description as string;
75
+ expect(description).toContain("Suggested tags: owner_approved, incident_followup.");
76
+ expect(description).not.toContain("Use for a choice made and its rationale.");
77
+ expect(description).not.toContain("Use for investigation notes and findings.");
80
78
  });
81
79
 
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
- });
80
+ test("transform replay does not duplicate tool registration", async () => {
81
+ const h = harness(); await h.start();
82
+ h.replay();
83
+ expect([...h.definitions.keys()].sort()).toEqual(["journal_read", "journal_search", "journal_write"]);
84
+ });
85
+
86
+ test("explicit false opts out without tools", async () => {
87
+ await writeConfig({ journal: { enabled: false } });
88
+ const h = harness(); await h.start();
89
+ expect([...h.definitions.keys()]).toEqual([]);
90
+ });
91
+
92
+ test("invalid journal typo fails closed and warns", async () => {
93
+ await writeConfig({ journal: { enabled_: false } });
94
+ const warnings: unknown[][] = [];
95
+ const originalWarn = console.warn;
96
+ console.warn = (...args) => warnings.push(args);
97
+ try {
98
+ const h = harness(); await h.start();
99
+ expect([...h.definitions.keys()]).toEqual([]);
100
+ expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: schema validation failed; journal tools will NOT be registered`]]);
101
+ } finally { console.warn = originalWarn; }
102
+ });
103
+
104
+ test("fails closed on unreadable config", async () => {
105
+ await writeConfig(undefined);
106
+ const warnings: unknown[][] = [];
107
+ const originalWarn = console.warn;
108
+ console.warn = (...args) => warnings.push(args);
109
+ try {
110
+ const h = harness(); await h.start();
111
+ expect([...h.definitions.keys()]).toEqual([]);
112
+ expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: read failed (EISDIR); journal tools will NOT be registered`]]);
113
+ } finally { console.warn = originalWarn; }
114
+ });
115
+
116
+ test("fails closed on malformed config", async () => {
117
+ await writeConfig("not json{{{");
118
+ const warnings: unknown[][] = [];
119
+ const originalWarn = console.warn;
120
+ console.warn = (...args) => warnings.push(args);
121
+ try {
122
+ const h = harness(); await h.start();
123
+ expect([...h.definitions.keys()]).toEqual([]);
124
+ expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: malformed JSON; journal tools will NOT be registered`]]);
125
+ } finally { console.warn = originalWarn; }
126
+ });
127
+
128
+ test("fails closed on invalid config", async () => {
129
+ await writeConfig({ journal: { enabled: "yes" } });
130
+ const warnings: unknown[][] = [];
131
+ const originalWarn = console.warn;
132
+ console.warn = (...args) => warnings.push(args);
133
+ try {
134
+ const h = harness(); await h.start();
135
+ expect([...h.definitions.keys()]).toEqual([]);
136
+ expect(warnings).toEqual([[`[agent-memory] journal config ${configPath}: schema validation failed; journal tools will NOT be registered`]]);
137
+ } finally { console.warn = originalWarn; }
138
+ });
139
+
140
+ test("default absent config emits no warning", async () => {
141
+ const warnings: unknown[][] = [];
142
+ const originalWarn = console.warn;
143
+ console.warn = (...args) => warnings.push(args);
144
+ try { const h = harness(); await h.start(); expect(warnings).toEqual([]); }
145
+ finally { console.warn = originalWarn; }
146
+ });
147
+
148
+ test("captures model/provider per session for interleaved context events", async () => {
149
+ const h = harness(); await h.start();
150
+ const hook = h.contextHook()!;
151
+ hook({ sessionID: "session-a", model: { id: "model-a", providerID: "provider-a" } });
152
+ hook({ sessionID: "session-b", model: { id: "model-b", providerID: "provider-b" } });
153
+ hook({ sessionID: "session-a", model: { id: "model-a2", providerID: "provider-a2" } });
154
+ await h.definitions.get("journal_write").execute({ title: "Title", body: "Body" }, { agent: "agent", sessionID: "session-b" });
155
+ const files = (await fs.readdir(journalDir)).filter((f) => f.endsWith(".md"));
156
+ expect(files).toHaveLength(1);
157
+ const text = await fs.readFile(path.join(journalDir, files[0]!), "utf-8");
158
+ expect(text).toContain("model: model-b");
159
+ expect(text).toContain("provider: provider-b");
160
+ expect(text).toContain("session_id: session-b");
161
+ });
162
+ });
163
+
164
+ describe("project journal index injection", () => {
165
+ const event = () => ({ sessionID: "s", model: { id: "m", providerID: "p" }, system: [] as Array<{ type: "text"; text: string }> });
166
+ async function project(body?: string) {
167
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "memory-index-"));
168
+ if (body !== undefined) await fs.writeFile(path.join(directory, "MEMORY.md"), body);
169
+ return directory;
106
170
  }
107
171
 
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();
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([]);
126
198
  }
127
- });
128
- }
199
+ } finally {
200
+ await fs.rm(empty, { recursive: true, force: true });
201
+ await fs.rm(missing, { recursive: true, force: true });
202
+ await fs.rm(unreadable, { force: true });
203
+ }
204
+ });
205
+
206
+ test("strips frontmatter; enforces UTF-8 byte cap for long Unicode tokens", async () => {
207
+ const directory = await project(`---\ntitle: hidden\n---\nvisible pointer`);
208
+ try { const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system[0]!.text).toContain("\n\nvisible pointer"); expect(e.system[0]!.text).not.toContain("hidden"); }
209
+ finally { await fs.rm(directory, { recursive: true, force: true }); }
210
+ const large = await project("界".repeat(8192));
211
+ try { const h = harness(large); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system[0]!.text).toBe("Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list."); }
212
+ finally { await fs.rm(large, { recursive: true, force: true }); }
213
+ });
214
+
215
+ test("disabled journal registers no hook or injection", async () => {
216
+ const directory = await project("pointer");
217
+ try { await writeConfig({ journal: { enabled: false } }); const h = harness(directory); await h.start(); expect(h.contextHook()).toBeUndefined(); }
218
+ finally { await fs.rm(directory, { recursive: true, force: true }); }
219
+ });
129
220
  });
package/src/plugin.ts CHANGED
@@ -1,61 +1,81 @@
1
- import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
2
-
3
- import {
4
- createJournalStore,
5
- loadConfig,
6
- } from "./journal";
7
- import {
8
- JournalRead,
9
- JournalSearch,
10
- JournalWrite,
11
- } from "./tools";
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { createJournalStore, loadConfig } from "./journal";
3
+ import { JournalRead, JournalSearch, JournalWrite } from "./tools";
12
4
  import type { JournalContext } from "./tools";
13
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";
14
9
 
15
- export const MemoryPlugin: Plugin = async ({ directory }) => {
16
- // Journal: opt-in via ~/.config/opencode/agent-memory.json
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;
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;
27
14
 
28
- // Mutable state updated by chat.message hook
29
- const journalCtx: JournalContext = {
30
- directory,
31
- model: "",
32
- provider: "",
33
- };
15
+ export default Plugin.define({
16
+ id: "opencode-agent-memory",
17
+ async setup(ctx) {
18
+ const loadedConfig = await loadConfig();
19
+ const { config } = loadedConfig;
20
+ const configUsable = loadedConfig.status === "ok" || loadedConfig.status === "missing";
21
+ if (!configUsable) {
22
+ console.warn(`[agent-memory] journal config ${loadedConfig.configPath}: ${loadedConfig.reason ?? loadedConfig.status}; journal tools will NOT be registered`);
23
+ return;
24
+ }
34
25
 
35
- let journalTools: Record<string, ToolDefinition> = {};
26
+ if (config.journal?.enabled === false) return;
36
27
 
37
- if (journalEnabled) {
38
28
  const journalStore = createJournalStore(undefined, config.cacheDir);
39
- // Warmup the embedder in the background; pass configured cacheDir so the
40
- // first-init-wins singleton caches to the correct directory.
41
29
  void warmupEmbedder(config.cacheDir).catch(() => {});
42
- journalTools = {
43
- journal_write: JournalWrite(journalStore, journalCtx, config.journal?.tags),
44
- journal_read: JournalRead(journalStore),
45
- journal_search: JournalSearch(journalStore),
30
+ const contexts = new Map<string, JournalContext>();
31
+ // Location.Info.directory is the project directory (there is no separate root field).
32
+ const directory = ctx.location.directory;
33
+ const tools = [
34
+ JournalWrite(journalStore, {
35
+ directory,
36
+ get model() { return ""; },
37
+ get provider() { return ""; },
38
+ }, config.journal?.tags),
39
+ JournalRead(journalStore),
40
+ JournalSearch(journalStore),
41
+ ];
42
+
43
+ // Register a per-session capture; a write resolves its own session's model.
44
+ const writeTool = tools[0]!;
45
+ writeTool.execute = async (input, toolCtx) => {
46
+ const sessionContext = contexts.get(toolCtx.sessionID);
47
+ const modelContext: JournalContext = {
48
+ directory,
49
+ model: sessionContext?.model ?? "",
50
+ provider: sessionContext?.provider ?? "",
51
+ };
52
+ const contextual = JournalWrite(journalStore, modelContext, config.journal?.tags);
53
+ return contextual.execute(input, toolCtx);
46
54
  };
47
- }
48
55
 
49
- return {
50
- "chat.message": async (input, _output) => {
51
- if (input.model) {
52
- journalCtx.model = input.model.modelID;
53
- journalCtx.provider = input.model.providerID;
56
+ await ctx.session.hook("context", async (input) => {
57
+ contexts.set(input.sessionID, {
58
+ directory,
59
+ model: input.model.id,
60
+ provider: input.model.providerID,
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.
54
71
  }
55
- },
56
-
57
- tool: {
58
- ...journalTools,
59
- },
60
- };
61
- };
72
+ });
73
+ await ctx.tool.transform((editor) => {
74
+ // Transforms may be replayed; avoid duplicate registrations by effective tool name.
75
+ const registered = new Set(editor.list().map((tool) => tool.name));
76
+ for (const tool of tools) {
77
+ if (!registered.has(tool.name)) editor.add(tool as never);
78
+ }
79
+ });
80
+ },
81
+ });
@@ -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
+ });