@ghilteras/opencode-agent-memory 0.3.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/src/memory.ts ADDED
@@ -0,0 +1,300 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ import yaml from "js-yaml";
6
+ import { z } from "zod";
7
+
8
+ import { atomicWriteFile, buildFrontmatterDocument, splitFrontmatter } from "./frontmatter";
9
+ import { getDefaultDescription } from "./letta";
10
+
11
+ export type MemoryScope = "global" | "project";
12
+
13
+ export type MemoryBlock = {
14
+ scope: MemoryScope;
15
+ label: string;
16
+ description: string;
17
+ limit: number;
18
+ readOnly: boolean;
19
+ value: string;
20
+ filePath: string;
21
+ lastModified: Date;
22
+ };
23
+
24
+
25
+ const FrontmatterSchema = z.looseObject({
26
+ label: z.string().min(1).optional(),
27
+ description: z.string().optional(),
28
+ limit: z.number().int().positive().optional(),
29
+ read_only: z.boolean().optional(),
30
+ modified_at: z.string().datetime().optional(),
31
+ });
32
+
33
+ type ParsedFrontmatter = z.infer<typeof FrontmatterSchema>;
34
+
35
+ function parseFrontmatter(frontmatterText: string | undefined): ParsedFrontmatter {
36
+ if (!frontmatterText) {
37
+ return {};
38
+ }
39
+
40
+ const loaded = yaml.load(frontmatterText);
41
+ const parsed = FrontmatterSchema.safeParse(loaded);
42
+ if (!parsed.success) {
43
+ throw new Error(`Invalid frontmatter: ${parsed.error.message}`);
44
+ }
45
+
46
+ return parsed.data;
47
+ }
48
+
49
+ const DEFAULT_LIMIT = 5000;
50
+
51
+ async function readBlockFile(
52
+ scope: MemoryScope,
53
+ filePath: string,
54
+ ): Promise<MemoryBlock> {
55
+ const [raw, stats] = await Promise.all([
56
+ fs.readFile(filePath, "utf-8"),
57
+ fs.stat(filePath),
58
+ ]);
59
+ const { frontmatterText, body } = splitFrontmatter(raw);
60
+ const fm = parseFrontmatter(frontmatterText);
61
+
62
+ const label = (fm.label ?? path.basename(filePath, path.extname(filePath))).trim();
63
+ const description = (fm.description && fm.description.trim().length > 0
64
+ ? fm.description
65
+ : getDefaultDescription(label)).trim();
66
+ const limit = fm.limit ?? DEFAULT_LIMIT;
67
+ const readOnly = (fm.read_only ?? false) === true;
68
+
69
+ return {
70
+ scope,
71
+ label,
72
+ description,
73
+ limit,
74
+ readOnly,
75
+ value: body.trim(),
76
+ filePath,
77
+ lastModified: fm.modified_at ? new Date(fm.modified_at) : stats.mtime,
78
+ };
79
+ }
80
+
81
+ async function writeBlockFile(
82
+ filePath: string,
83
+ block: Pick<MemoryBlock, "label" | "description" | "limit" | "readOnly" | "value">,
84
+ ): Promise<Date> {
85
+ const now = new Date();
86
+ const content = buildFrontmatterDocument(
87
+ {
88
+ label: block.label,
89
+ description: block.description,
90
+ limit: block.limit,
91
+ read_only: block.readOnly,
92
+ modified_at: now.toISOString(),
93
+ },
94
+ block.value,
95
+ );
96
+
97
+ await atomicWriteFile(filePath, content);
98
+
99
+ return now;
100
+ }
101
+
102
+ function validateLabel(label: string): string {
103
+ const trimmed = label.trim();
104
+ if (!/^[a-z0-9][a-z0-9-_]{1,60}$/i.test(trimmed)) {
105
+ throw new Error(
106
+ `Invalid label "${label}". Use letters/numbers/dash/underscore (2-61 chars).`,
107
+ );
108
+ }
109
+ return trimmed;
110
+ }
111
+
112
+ export type MemoryStore = {
113
+ ensureSeed(): Promise<void>;
114
+ listBlocks(scope: MemoryScope | "all"): Promise<MemoryBlock[]>;
115
+ getBlock(scope: MemoryScope, label: string): Promise<MemoryBlock>;
116
+ setBlock(
117
+ scope: MemoryScope,
118
+ label: string,
119
+ value: string,
120
+ opts?: { description?: string; limit?: number },
121
+ ): Promise<Date>;
122
+ replaceInBlock(scope: MemoryScope, label: string, oldText: string, newText: string): Promise<Date>;
123
+ };
124
+
125
+ const SEED_BLOCKS: Array<{ scope: MemoryScope; label: string }> = [
126
+ { scope: "global", label: "persona" },
127
+ { scope: "global", label: "human" },
128
+ { scope: "project", label: "project" },
129
+ ];
130
+
131
+ function scopeDir(projectDirectory: string, scope: MemoryScope): string {
132
+ return scope === "global"
133
+ ? path.join(os.homedir(), ".config", "opencode", "memory")
134
+ : path.join(projectDirectory, ".opencode", "memory");
135
+ }
136
+
137
+ async function exists(filePath: string): Promise<boolean> {
138
+ try {
139
+ await fs.access(filePath);
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ async function ensureGitignore(projectDirectory: string): Promise<void> {
147
+ const memoryDir = path.join(projectDirectory, ".opencode", "memory");
148
+ const gitignorePath = path.join(memoryDir, ".gitignore");
149
+
150
+ await fs.mkdir(memoryDir, { recursive: true });
151
+
152
+ if (await exists(gitignorePath)) {
153
+ return;
154
+ }
155
+
156
+ await fs.writeFile(gitignorePath, "*\n", "utf-8");
157
+ }
158
+
159
+ function stableSortBlocks(blocks: MemoryBlock[]): MemoryBlock[] {
160
+ // Stable ordering for prompt caching (if provider supported).
161
+ // Prefer a small set of canonical blocks first.
162
+ const priority = (block: MemoryBlock): [number, string] => {
163
+ if (block.scope === "global" && block.label === "persona") return [0, block.label];
164
+ if (block.scope === "global" && block.label === "human") return [1, block.label];
165
+ if (block.scope === "project" && block.label === "project") return [2, block.label];
166
+
167
+ const scopeBase = block.scope === "global" ? 10 : 20;
168
+ return [scopeBase, block.label];
169
+ };
170
+
171
+ blocks.sort((a, b) => {
172
+ const [pa, la] = priority(a);
173
+ const [pb, lb] = priority(b);
174
+ if (pa !== pb) return pa - pb;
175
+ return la.localeCompare(lb);
176
+ });
177
+
178
+ return blocks;
179
+ }
180
+
181
+ export function createMemoryStore(projectDirectory: string): MemoryStore {
182
+ return {
183
+ async ensureSeed() {
184
+ await ensureGitignore(projectDirectory);
185
+
186
+ for (const seed of SEED_BLOCKS) {
187
+ const dir = scopeDir(projectDirectory, seed.scope);
188
+ await fs.mkdir(dir, { recursive: true });
189
+
190
+ const filePath = path.join(dir, `${seed.label}.md`);
191
+ if (await exists(filePath)) {
192
+ continue;
193
+ }
194
+
195
+ await writeBlockFile(filePath, {
196
+ label: seed.label,
197
+ description: "",
198
+ limit: 5000,
199
+ readOnly: false,
200
+ value: "",
201
+ });
202
+ }
203
+ },
204
+
205
+ async listBlocks(scope) {
206
+ const scopes: MemoryScope[] = scope === "all" ? ["global", "project"] : [scope];
207
+ const blocks: MemoryBlock[] = [];
208
+
209
+ for (const s of scopes) {
210
+ const dir = scopeDir(projectDirectory, s);
211
+ if (!(await exists(dir))) {
212
+ continue;
213
+ }
214
+
215
+ const entries = await fs.readdir(dir, { withFileTypes: true });
216
+ for (const entry of entries) {
217
+ if (!entry.isFile()) continue;
218
+ if (!entry.name.endsWith(".md")) continue;
219
+
220
+ const filePath = path.join(dir, entry.name);
221
+ try {
222
+ blocks.push(await readBlockFile(s, filePath));
223
+ } catch (err) {
224
+ // Ignore invalid files silently for now, but keep going.
225
+ }
226
+ }
227
+ }
228
+
229
+ return stableSortBlocks(blocks)
230
+ },
231
+
232
+ async getBlock(scope, label) {
233
+ const safeLabel = validateLabel(label);
234
+ const dir = scopeDir(projectDirectory, scope);
235
+ const filePath = path.join(dir, `${safeLabel}.md`);
236
+
237
+ if (!(await exists(filePath))) {
238
+ throw new Error(`Memory block not found: ${scope}:${safeLabel}`);
239
+ }
240
+
241
+ return readBlockFile(scope, filePath);
242
+ },
243
+
244
+ async setBlock(scope, label, value, opts) {
245
+ const safeLabel = validateLabel(label);
246
+ const dir = scopeDir(projectDirectory, scope);
247
+ await fs.mkdir(dir, { recursive: true });
248
+
249
+ const filePath = path.join(dir, `${safeLabel}.md`);
250
+ const existing = (await exists(filePath)) ? await readBlockFile(scope, filePath) : undefined;
251
+
252
+ if (existing?.readOnly) {
253
+ throw new Error(`Memory block is read-only: ${scope}:${safeLabel}`);
254
+ }
255
+
256
+ const description = (opts?.description ?? existing?.description ?? "").trim();
257
+ const limit = opts?.limit ?? existing?.limit ?? 5000;
258
+
259
+ if (value.length > limit) {
260
+ throw new Error(
261
+ `Value too large for ${scope}:${safeLabel} (chars=${value.length}, limit=${limit}).`,
262
+ );
263
+ }
264
+
265
+ return writeBlockFile(filePath, {
266
+ label: safeLabel,
267
+ description,
268
+ limit,
269
+ readOnly: existing?.readOnly ?? false,
270
+ value,
271
+ });
272
+ },
273
+
274
+ async replaceInBlock(scope, label, oldText, newText) {
275
+ const block = await this.getBlock(scope, label);
276
+ if (block.readOnly) {
277
+ throw new Error(`Memory block is read-only: ${scope}:${block.label}`);
278
+ }
279
+
280
+ if (!block.value.includes(oldText)) {
281
+ throw new Error(`Old text not found in ${scope}:${block.label}.`);
282
+ }
283
+
284
+ const next = block.value.replace(oldText, newText);
285
+ if (next.length > block.limit) {
286
+ throw new Error(
287
+ `Value too large for ${scope}:${block.label} after replace (chars=${next.length}, limit=${block.limit}).`,
288
+ );
289
+ }
290
+
291
+ return writeBlockFile(block.filePath, {
292
+ label: block.label,
293
+ description: block.description,
294
+ limit: block.limit,
295
+ readOnly: block.readOnly,
296
+ value: next,
297
+ });
298
+ },
299
+ };
300
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,80 @@
1
+ import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
2
+
3
+ import {
4
+ buildJournalSystemNote,
5
+ createJournalStore,
6
+ loadConfig,
7
+ } from "./journal";
8
+ import { createMemoryStore } from "./memory";
9
+ import { renderMemoryBlocks } from "./prompt";
10
+ import {
11
+ JournalRead,
12
+ JournalSearch,
13
+ JournalWrite,
14
+ MemoryList,
15
+ MemoryReplace,
16
+ MemorySet,
17
+ } from "./tools";
18
+ import type { JournalContext } from "./tools";
19
+
20
+ export const MemoryPlugin: Plugin = async ({ directory }) => {
21
+ const store = createMemoryStore(directory);
22
+ await store.ensureSeed();
23
+
24
+ // Journal: opt-in via ~/.config/opencode/agent-memory.json
25
+ const config = await loadConfig();
26
+ const journalEnabled = config.journal?.enabled === true;
27
+
28
+ // Mutable state updated by chat.message hook
29
+ const journalCtx: JournalContext = {
30
+ directory,
31
+ model: "",
32
+ provider: "",
33
+ };
34
+
35
+ let journalTools: Record<string, ToolDefinition> = {};
36
+ let journalSystemNote = "";
37
+
38
+ if (journalEnabled) {
39
+ const journalStore = createJournalStore();
40
+ journalTools = {
41
+ journal_write: JournalWrite(journalStore, journalCtx),
42
+ journal_read: JournalRead(journalStore),
43
+ journal_search: JournalSearch(journalStore),
44
+ };
45
+ journalSystemNote = buildJournalSystemNote(config.journal?.tags);
46
+ }
47
+
48
+ return {
49
+ "chat.message": async (input, _output) => {
50
+ if (input.model) {
51
+ journalCtx.model = input.model.modelID;
52
+ journalCtx.provider = input.model.providerID;
53
+ }
54
+ },
55
+
56
+ "experimental.chat.system.transform": async (_input, output) => {
57
+ const blocks = await store.listBlocks("all");
58
+ const xml = renderMemoryBlocks(blocks);
59
+ if (!xml) return;
60
+
61
+ // Append memory blocks at the END of the system prompt (stable tail).
62
+ // On opencode 1.18.x the hook receives a 1-element array, so splice(1) and push are byte-identical;
63
+ // push is correct under both current and future (V2 runner) array shapes and keeps the static
64
+ // provider/instructions prefix cacheable.
65
+ output.system.push(xml);
66
+
67
+ // Append journal instructions at the end (preserves memory block cache)
68
+ if (journalSystemNote) {
69
+ output.system.push(journalSystemNote);
70
+ }
71
+ },
72
+
73
+ tool: {
74
+ memory_list: MemoryList(store),
75
+ memory_set: MemorySet(store),
76
+ memory_replace: MemoryReplace(store),
77
+ ...journalTools,
78
+ },
79
+ };
80
+ };
@@ -0,0 +1,158 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { MemoryBlock } from "./memory";
4
+ import { renderMemoryBlocks } from "./prompt";
5
+
6
+ describe("renderMemoryBlocks", () => {
7
+ test("renders stable xml with line numbers", () => {
8
+ const xml = renderMemoryBlocks([
9
+ {
10
+ scope: "global",
11
+ label: "human",
12
+ description: "User prefs",
13
+ limit: 10,
14
+ readOnly: false,
15
+ value: "line one\nline two",
16
+ filePath: "/tmp/human.md",
17
+ lastModified: new Date("2025-01-15T10:30:00Z"),
18
+ },
19
+ ]);
20
+
21
+ expect(xml).toContain("<memory_blocks>");
22
+ expect(xml).toContain("The following memory blocks are currently engaged");
23
+ expect(xml).toContain("<human>");
24
+ expect(xml).toContain("<warning>");
25
+ expect(xml).toContain("Do NOT include line number prefixes");
26
+ expect(xml).toContain("1→ line one");
27
+ expect(xml).toContain("2→ line two");
28
+ });
29
+
30
+ test("includes memory instructions section", () => {
31
+ const xml = renderMemoryBlocks([
32
+ {
33
+ scope: "global",
34
+ label: "human",
35
+ description: "User prefs",
36
+ limit: 10,
37
+ readOnly: false,
38
+ value: "hi",
39
+ filePath: "/tmp/human.md",
40
+ lastModified: new Date("2025-01-15T10:30:00Z"),
41
+ },
42
+ ]);
43
+
44
+ expect(xml).toContain("<memory_instructions>");
45
+ expect(xml).toContain("<memory_editing>");
46
+ expect(xml).toContain("persistent memory");
47
+ expect(xml).toContain("<memory_scopes>");
48
+ expect(xml).toContain("</memory_instructions>");
49
+ });
50
+
51
+ test("includes per-block last_modified in metadata", () => {
52
+ const xml = renderMemoryBlocks([
53
+ {
54
+ scope: "global",
55
+ label: "human",
56
+ description: "User prefs",
57
+ limit: 10,
58
+ readOnly: false,
59
+ value: "hi",
60
+ filePath: "/tmp/human.md",
61
+ lastModified: new Date("2025-01-15T10:30:00Z"),
62
+ },
63
+ ]);
64
+
65
+ expect(xml).toContain("last_modified=2025-01-15T10:30:00.000Z");
66
+ expect(xml).not.toContain("<memory_metadata>");
67
+ expect(xml).not.toContain("The current system date is:");
68
+ });
69
+
70
+ test("does not contain volatile new Date() content", () => {
71
+ const block: MemoryBlock = {
72
+ scope: "global",
73
+ label: "human",
74
+ description: "User prefs",
75
+ limit: 10,
76
+ readOnly: false,
77
+ value: "hi",
78
+ filePath: "/tmp/human.md",
79
+ lastModified: new Date("2025-01-15T10:30:00Z"),
80
+ };
81
+ const xml = renderMemoryBlocks([block]);
82
+ const xml2 = renderMemoryBlocks([block]);
83
+
84
+ expect(xml).toBe(xml2);
85
+ });
86
+
87
+ test("handles empty value gracefully", () => {
88
+ const xml = renderMemoryBlocks([
89
+ {
90
+ scope: "project",
91
+ label: "notes",
92
+ description: "Project notes",
93
+ limit: 1000,
94
+ readOnly: false,
95
+ value: "",
96
+ filePath: "/tmp/notes.md",
97
+ lastModified: new Date("2025-01-15T10:30:00Z"),
98
+ },
99
+ ]);
100
+
101
+ expect(xml).toContain("<notes>");
102
+ expect(xml).toContain("<value>\n\n</value>");
103
+ // Empty value - the value section should be truly empty
104
+ const valueMatch = xml.match(/<value>\n(.*?)\n<\/value>/s);
105
+ expect(valueMatch).toBeTruthy();
106
+ expect(valueMatch![1]).toBe("");
107
+ });
108
+
109
+ test("renders complete output structure", () => {
110
+ const xml = renderMemoryBlocks([
111
+ {
112
+ scope: "global",
113
+ label: "persona",
114
+ description: "Your persona",
115
+ limit: 5000,
116
+ readOnly: false,
117
+ value: "I am helpful\nand concise",
118
+ filePath: "/tmp/persona.md",
119
+ lastModified: new Date("2025-01-15T08:00:00Z"),
120
+ },
121
+ {
122
+ scope: "project",
123
+ label: "project",
124
+ description: "Project info",
125
+ limit: 5000,
126
+ readOnly: true,
127
+ value: "This is a TypeScript project",
128
+ filePath: "/tmp/project.md",
129
+ lastModified: new Date("2025-01-15T10:30:00Z"),
130
+ },
131
+ ]);
132
+
133
+ // Check overall structure order
134
+ const instructionsIndex = xml.indexOf("<memory_instructions>");
135
+ const blocksIndex = xml.indexOf("<memory_blocks>");
136
+
137
+ expect(instructionsIndex).toBeLessThan(blocksIndex);
138
+ expect(xml).not.toContain("<memory_metadata>");
139
+
140
+ // Check both blocks present
141
+ expect(xml).toContain("<persona>");
142
+ expect(xml).toContain("</persona>");
143
+ expect(xml).toContain("<project>");
144
+ expect(xml).toContain("</project>");
145
+
146
+ // Check per-block last_modified timestamps
147
+ expect(xml).toContain("last_modified=2025-01-15T08:00:00.000Z");
148
+ expect(xml).toContain("last_modified=2025-01-15T10:30:00.000Z");
149
+
150
+ // Check line numbers in multi-line value
151
+ expect(xml).toContain("1→ I am helpful");
152
+ expect(xml).toContain("2→ and concise");
153
+
154
+ // Check read_only rendered
155
+ expect(xml).toContain("read_only=true");
156
+ expect(xml).toContain("read_only=false");
157
+ });
158
+ });
package/src/prompt.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { MemoryBlock } from "./memory";
2
+ import { MEMORY_INSTRUCTIONS } from "./letta";
3
+
4
+ const LINE_NUMBER_WARNING =
5
+ "# NOTE: Line numbers shown below (with arrows like '1→') are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.";
6
+
7
+ export function renderMemoryBlocks(blocks: MemoryBlock[]): string {
8
+ if (blocks.length === 0) {
9
+ return "";
10
+ }
11
+
12
+ const parts: string[] = [
13
+ MEMORY_INSTRUCTIONS,
14
+ "",
15
+ "<memory_blocks>",
16
+ "The following memory blocks are currently engaged in your core memory unit:",
17
+ "",
18
+ ];
19
+
20
+ for (const block of blocks) {
21
+ // escape xml
22
+ const desc = block.description
23
+ .replaceAll("&", "&amp;")
24
+ .replaceAll("<", "&lt;")
25
+ .replaceAll(">", "&gt;");
26
+
27
+ const numberedValue = block.value
28
+ ? block.value.split("\n").map((line, i) => `${i + 1}→ ${line}`).join("\n")
29
+ : "";
30
+
31
+ const memoryBlock = `<${block.label}>
32
+ <description>
33
+ ${desc}
34
+ </description>
35
+ <metadata>
36
+ ${[
37
+ `- chars_current=${block.value.length}`,
38
+ `- chars_limit=${block.limit}`,
39
+ `- read_only=${block.readOnly}`,
40
+ `- scope=${block.scope}`,
41
+ `- last_modified=${block.lastModified.toISOString()}`,
42
+ ].join("\n")}
43
+ </metadata>
44
+ <warning>
45
+ ${LINE_NUMBER_WARNING}
46
+ </warning>
47
+ <value>
48
+ ${numberedValue}
49
+ </value>
50
+ </${block.label}>`;
51
+
52
+ parts.push(memoryBlock);
53
+ }
54
+
55
+ parts.push("</memory_blocks>");
56
+
57
+ return parts.join("\n");
58
+ }