@ghilteras/opencode-agent-memory 1.2.0 → 2.0.1

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,39 +0,0 @@
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";
5
-
6
- if (process.env.HOME === undefined || !process.env.HOME.startsWith("/tmp/")) {
7
- throw new Error("plugin tests require HOME pointed at a /tmp fixture directory; run via `bun run test:plugin`");
8
- }
9
-
10
- const configDir = path.join(process.env.HOME, ".config", "opencode");
11
-
12
- mock.module("./embeddings", () => ({
13
- ...realEmbeddings,
14
- generateEmbedding: async () => Array.from({ length: 384 }, (_, i) => Math.sin(i) * 0.5),
15
- warmupEmbedder: async () => {},
16
- }));
17
-
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
- }
39
- });
@@ -1,220 +0,0 @@
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");
20
-
21
- mock.module("./embeddings", () => ({
22
- ...realEmbeddings,
23
- generateEmbedding: async () => Array.from({ length: 384 }, (_, i) => Math.sin(i) * 0.5),
24
- warmupEmbedder: async () => {},
25
- }));
26
-
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) };
52
- }
53
-
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"]);
65
- });
66
-
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.");
78
- });
79
-
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;
170
- }
171
-
172
- test("injects a byte-stable rendered index at exact word fit", async () => {
173
- const directory = await project(Array(330).fill("pointer").join(" "));
174
- try {
175
- const h = harness(directory); await h.start();
176
- const first = event(); const second = event();
177
- await h.contextHook()!(first); await h.contextHook()!(second);
178
- expect(first.system).toEqual(second.system);
179
- expect(first.system).toHaveLength(1);
180
- expect(first.system[0]!.text).toBe(`This is the project's journal-pointer index (MEMORY.md at the repository root). Retrieve entries with journal_search/journal_read; older lines remain relevant.\n\n${Array(330).fill("pointer").join(" ")}`);
181
- } finally { await fs.rm(directory, { recursive: true, force: true }); }
182
- });
183
-
184
- test("331 words injects only the fixed notice", async () => {
185
- const directory = await project(Array(331).fill("pointer").join(" "));
186
- try { const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system).toEqual([{ type: "text", text: "Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list." }]); }
187
- finally { await fs.rm(directory, { recursive: true, force: true }); }
188
- });
189
-
190
- test("empty, missing, and unreadable indexes inject nothing", async () => {
191
- const empty = await project(" \n\t");
192
- const missing = await project();
193
- const unreadable = path.join(os.tmpdir(), `memory-index-file-${Date.now()}`);
194
- await fs.writeFile(unreadable, "not a directory");
195
- try {
196
- for (const directory of [empty, missing, unreadable]) {
197
- const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system).toEqual([]);
198
- }
199
- } finally {
200
- await fs.rm(empty, { recursive: true, force: true });
201
- await fs.rm(missing, { recursive: true, force: true });
202
- await fs.rm(unreadable, { force: true });
203
- }
204
- });
205
-
206
- test("strips frontmatter; enforces UTF-8 byte cap for long Unicode tokens", async () => {
207
- const directory = await project(`---\ntitle: hidden\n---\nvisible pointer`);
208
- try { const h = harness(directory); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system[0]!.text).toContain("\n\nvisible pointer"); expect(e.system[0]!.text).not.toContain("hidden"); }
209
- finally { await fs.rm(directory, { recursive: true, force: true }); }
210
- const large = await project("界".repeat(8192));
211
- try { const h = harness(large); await h.start(); const e = event(); await h.contextHook()!(e); expect(e.system[0]!.text).toBe("Project journal index (MEMORY.md) exceeds the injection cap; read the file at the repository root directly for the pointer list."); }
212
- finally { await fs.rm(large, { recursive: true, force: true }); }
213
- });
214
-
215
- test("disabled journal registers no hook or injection", async () => {
216
- const directory = await project("pointer");
217
- try { await writeConfig({ journal: { enabled: false } }); const h = harness(directory); await h.start(); expect(h.contextHook()).toBeUndefined(); }
218
- finally { await fs.rm(directory, { recursive: true, force: true }); }
219
- });
220
- });
@@ -1,15 +0,0 @@
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
- });
package/src/tools.test.ts DELETED
@@ -1,115 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { JournalRead, JournalSearch, JournalWrite } from "./tools";
3
- import type { JournalContext } from "./tools";
4
- import type { JournalEntry, JournalStore } from "./journal";
5
-
6
- const context: JournalContext = { directory: "/tmp/project", model: "m", provider: "p" };
7
- function entry(): JournalEntry {
8
- return { id: "20260101-000000-000", title: "Title", project: "/tmp", model: "m", provider: "p", agent: "a", sessionId: "s", created: new Date("2026-01-01T00:00:00.000Z"), tags: ["tag"], body: "Body", filePath: "/tmp/entry.md" };
9
- }
10
- const baseStore: JournalStore = {
11
- write: async () => entry(),
12
- read: async () => entry(),
13
- search: async () => ({ entries: [entry()], total: 1, allTags: ["tag"] }),
14
- };
15
-
16
- function valid(schema: any, value: any): boolean {
17
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
18
- if (schema.additionalProperties === false && Object.keys(value).some((key) => !(key in schema.properties))) return false;
19
- if (schema.required?.some((key: string) => !(key in value))) return false;
20
- for (const [key, field] of Object.entries<any>(schema.properties)) {
21
- if (!(key in value)) continue;
22
- const actual = value[key];
23
- if (field.type === "string" && typeof actual !== "string") return false;
24
- if (field.type === "integer" && (!Number.isInteger(actual) || actual < (field.minimum ?? -Infinity))) return false;
25
- }
26
- return true;
27
- }
28
-
29
- describe("v2 journal tools", () => {
30
- test("preserves the recognizable names and descriptions", () => {
31
- expect([JournalWrite(baseStore, context).name, JournalRead(baseStore).name, JournalSearch(baseStore).name]).toEqual(["journal_write", "journal_read", "journal_search"]);
32
- expect(JournalWrite(baseStore, context).description).toContain("append-only");
33
- expect(JournalRead(baseStore).description).toContain("by its ID");
34
- expect(JournalSearch(baseStore).description).toContain("semantic similarity");
35
- });
36
-
37
- test("journal_write description preserves its behavioral guidance", () => {
38
- const description = JournalWrite(baseStore, context).description;
39
- expect(description).toMatch(/insights.*discoveries|discoveries.*decisions/i);
40
- expect(description).toMatch(/insights.*technical discoveries.*design decisions.*observations/i);
41
- expect(description).toMatch(/never edit old ones/i);
42
- expect(description).toMatch(/comma-separated/i);
43
- expect(description).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
44
- expect(description).not.toContain("Suggested tags:");
45
- const tagged = JournalWrite(baseStore, context, [{ name: "focus", description: "private detail" }]).description;
46
- expect(tagged).toContain("Suggested tags: focus.");
47
- expect(tagged).not.toContain("private detail");
48
- });
49
-
50
- test("read and search descriptions retain their distinct contracts", () => {
51
- const read = JournalRead(baseStore).description;
52
- expect(read).not.toMatch(/append.only|cannot be (edited|modified)/i);
53
- expect(read).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
54
- const search = JournalSearch(baseStore);
55
- expect(search.description).toMatch(/filters are optional/i);
56
- expect(search.description).toMatch(/global across all projects/i);
57
- expect(search.input.properties).toHaveProperty("project");
58
- expect(search.input.properties).toHaveProperty("offset");
59
- expect(search.description).toMatch(/paginate/i);
60
- });
61
-
62
- test("descriptions are nonempty and do not name sibling tools", () => {
63
- const descriptions = [JournalWrite(baseStore, context).description, JournalRead(baseStore).description, JournalSearch(baseStore).description];
64
- expect(descriptions.every((description) => description.trim().length > 0)).toBe(true);
65
- for (const [index, description] of descriptions.entries()) {
66
- for (const [otherIndex, name] of ["journal_write", "journal_read", "journal_search"].entries()) {
67
- if (index !== otherIndex) expect(description).not.toContain(name);
68
- }
69
- }
70
- });
71
-
72
- test("uses JSON schemas, rejects malformed and unknown arguments", () => {
73
- const write = JournalWrite(baseStore, context).input as any;
74
- const read = JournalRead(baseStore).input as any;
75
- const search = JournalSearch(baseStore).input as any;
76
- expect(write.type).toBe("object");
77
- expect(valid(write, { title: "t", body: "b" })).toBe(true);
78
- expect(valid(write, { title: 4, body: "b" })).toBe(false);
79
- expect(valid(write, { title: "t", body: "b", extra: true })).toBe(false);
80
- expect(valid(read, {})).toBe(false);
81
- expect(valid(read, { id: "id", extra: 1 })).toBe(false);
82
- expect(valid(search, { limit: 0 })).toBe(false);
83
- expect(valid(search, { offset: 1.5 })).toBe(false);
84
- expect(valid(search, { unexpected: true })).toBe(false);
85
- expect(valid(search, { tags: "a,b", limit: 2, offset: 0 })).toBe(true);
86
- });
87
-
88
- test("all execute methods return the v2 {content} result", async () => {
89
- const writeResult = await JournalWrite(baseStore, context).execute({ title: "t", body: "b" }, { agent: "a", sessionID: "s" });
90
- const readResult = await JournalRead(baseStore).execute({ id: "entry" }, {} as any);
91
- const searchResult = await JournalSearch(baseStore).execute({}, {} as any);
92
- for (const result of [writeResult, readResult, searchResult]) {
93
- expect(Object.keys(result)).toEqual(["content"]);
94
- expect(typeof result.content).toBe("string");
95
- }
96
- expect(writeResult.content).toContain("Journal entry created:");
97
- expect(readResult.content).toContain("Body");
98
- expect(searchResult.content).toContain("Found 1 entries");
99
- });
100
-
101
- test("retains bounded tag inventory output formatting", async () => {
102
- const populated: JournalStore = { ...baseStore, search: async () => ({ entries: [entry()], total: 1, allTags: ["alpha", "beta"] }) };
103
- expect((await JournalSearch(populated).execute({}, {} as any)).content).toContain("Tags in use: alpha, beta");
104
- const empty: JournalStore = { ...baseStore, search: async () => ({ entries: [], total: 0, allTags: [] }) };
105
- expect((await JournalSearch(empty).execute({}, {} as any)).content).toBe("No journal entries found.");
106
- const store: JournalStore = { ...baseStore, search: async () => ({ entries: [], total: 0, allTags: Array.from({ length: 21 }, (_, i) => `tag-${i}`) }) };
107
- const result = await JournalSearch(store).execute({}, {} as any);
108
- expect(result.content).toBe("No journal entries found.\nTags in use: 21 (filter with tags=...)");
109
- });
110
-
111
- test("journal_read formats metadata before the body", async () => {
112
- const result = await JournalRead(baseStore).execute({ id: "entry" }, {} as any);
113
- expect(result.content).toBe("title: Title\ncreated: 2026-01-01T00:00:00.000Z\nproject: /tmp\nmodel: m\nprovider: p\nagent: a\nsession: s\ntags: tag\n\nBody");
114
- });
115
- });