opencode-memory-tool 1.0.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.
Files changed (3) hide show
  1. package/README.md +54 -0
  2. package/index.ts +605 -0
  3. package/package.json +18 -0
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # opencode-memory
2
+
3
+ Standalone OpenCode plugin that ports the `patch-memory` fork patch into an external plugin.
4
+
5
+ ## What it does
6
+
7
+ - **Registers a `memory` tool** via the OpenCode plugin `tool` hook
8
+ - **Injects memory context** into the system prompt via `experimental.chat.system.transform`
9
+
10
+ ## Memory tiers
11
+
12
+ | Path | Scope | Location |
13
+ |------|-------|----------|
14
+ | `/memories/` | User | `$OPENCODE_CONFIG_DIR/memories/` |
15
+ | `/memories/session/` | Session | `$OPENCODE_CONFIG_DIR/memories/session/<sessionID>/` |
16
+ | `/memories/repo/` | Repo | `<project>/.opencode/memories/` |
17
+
18
+ ## Commands
19
+
20
+ | Command | Description |
21
+ |---------|-------------|
22
+ | `view` | List directory or read file (with optional line range) |
23
+ | `create` | Create a new file (fails if exists) |
24
+ | `str_replace` | Replace exact string (must be unique in file) |
25
+ | `insert` | Insert text at a line number |
26
+ | `delete` | Delete file or directory |
27
+ | `rename` | Move/rename within same scope |
28
+
29
+ ## Installation
30
+
31
+ ### npm (recommended)
32
+
33
+ ```json
34
+ {
35
+ "plugin": ["opencode-memory-tool"]
36
+ }
37
+ ```
38
+
39
+ ### Local file path (npm not desired)
40
+
41
+ If you don't want to install with npm, use `file://` paths. Add to `opencode.json`:
42
+
43
+ ```json
44
+ {
45
+ "plugin": ["file:///path/to/opencode-memory/index.ts"]
46
+ }
47
+ ```
48
+
49
+ ## Relationship to patch-memory.ts
50
+
51
+ This plugin replaces the registry.ts and prompt.ts patches from `patch-memory.ts`.
52
+ The `memory.ts` and `memory.txt` file creation in the patch script is kept for now
53
+ as a compatibility shim, but tool registration and system prompt injection are
54
+ handled entirely by this plugin.
package/index.ts ADDED
@@ -0,0 +1,605 @@
1
+ /**
2
+ * opencode-memory — Persistent memory plugin for OpenCode
3
+ *
4
+ * Registers the MemoryTool via the plugin `tool` hook and auto-injects
5
+ * memory contents into the system prompt via `experimental.chat.system.transform`.
6
+ *
7
+ * Memory is organized under /memories/ with three tiers:
8
+ * - /memories/ — User-scoped: global across all projects ($OPENCODE_CONFIG_DIR/memories/)
9
+ * - /memories/session/ — Session-scoped: cleared when session ends
10
+ * - /memories/repo/ — Repo-scoped: stored in <project>/.opencode/memories/repo/
11
+ */
12
+ import { z } from "zod"
13
+ import type { Plugin } from "@opencode-ai/plugin"
14
+ import { tool } from "@opencode-ai/plugin"
15
+ import path from "path"
16
+ import fs from "fs/promises"
17
+ import { existsSync, mkdirSync, statSync, readdirSync, rmSync, readFileSync } from "fs"
18
+ import os from "os"
19
+
20
+ // ─── Constants ────────────────────────────────────────────────────────────────
21
+
22
+ const RETENTION_MS = 14 * 24 * 60 * 60 * 1000
23
+ const CLEANUP_INTERVAL_MS = 60 * 60 * 1000
24
+
25
+ function configDir(): string {
26
+ return (
27
+ process.env.OPENCODE_CONFIG_DIR ??
28
+ path.join(os.homedir(), ".config", "opencode")
29
+ )
30
+ }
31
+
32
+ function userMemoryRoot(): string {
33
+ return path.join(configDir(), "memories")
34
+ }
35
+
36
+ function sessionMemoryRoot(sessionID: string): string {
37
+ const safe = sessionID.replace(/[^a-zA-Z0-9_.-]/g, "_")
38
+ return path.join(userMemoryRoot(), "session", safe)
39
+ }
40
+
41
+ function repoMemoryRoot(projectDir: string): string {
42
+ return path.join(projectDir, ".opencode", "memories")
43
+ }
44
+
45
+ function ensure(dir: string) {
46
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
47
+ }
48
+
49
+ function validatePath(p: string): string | undefined {
50
+ if (p.includes("..")) return "Error: Path traversal is not allowed"
51
+ const segments = p.split("/").filter((s) => s.length > 0)
52
+ if (segments.some((s) => s === ".")) return "Error: Path traversal is not allowed"
53
+ if (segments[0] !== "memories") return "Error: All memory paths must start with /memories/"
54
+ return undefined
55
+ }
56
+
57
+ type Scope = "user" | "session" | "repo"
58
+
59
+ function resolvePath(
60
+ virtPath: string,
61
+ sessionID: string,
62
+ projectDir: string,
63
+ ): { real: string; scope: Scope } {
64
+ let normalized = virtPath
65
+ while (normalized.includes("//")) normalized = normalized.split("//").join("/")
66
+ if (!normalized.startsWith("/")) normalized = "/" + normalized
67
+
68
+ if (normalized.startsWith("/memories/repo/") || normalized === "/memories/repo") {
69
+ const rel = normalized.slice("/memories/repo".length).replace(/^\//, "")
70
+ return { real: path.join(repoMemoryRoot(projectDir), rel), scope: "repo" }
71
+ }
72
+ if (normalized.startsWith("/memories/session/") || normalized === "/memories/session") {
73
+ const rel = normalized.slice("/memories/session".length).replace(/^\//, "")
74
+ return { real: path.join(sessionMemoryRoot(sessionID), rel), scope: "session" }
75
+ }
76
+ if (normalized === "/memories" || normalized === "/memories/") {
77
+ return { real: userMemoryRoot(), scope: "user" }
78
+ }
79
+ const rel = normalized.slice("/memories".length).replace(/^\//, "")
80
+ if (rel === "session" || rel.startsWith("session/")) {
81
+ const sub = rel.slice("session".length).replace(/^\//, "")
82
+ return { real: path.join(sessionMemoryRoot(sessionID), sub), scope: "session" }
83
+ }
84
+ if (rel === "repo" || rel.startsWith("repo/")) {
85
+ const sub = rel.slice("repo".length).replace(/^\//, "")
86
+ return { real: path.join(repoMemoryRoot(projectDir), sub), scope: "repo" }
87
+ }
88
+ return { real: path.join(userMemoryRoot(), rel), scope: "user" }
89
+ }
90
+
91
+ // ─── Formatting helpers ───────────────────────────────────────────────────────
92
+
93
+ function fmtLine(n: number) {
94
+ return String(n).padStart(6, " ")
95
+ }
96
+
97
+ function formatFileContent(virtPath: string, content: string) {
98
+ const lines = content.split("\n")
99
+ const numbered = lines.map((line, i) => fmtLine(i + 1) + "\t" + line)
100
+ return "Here's the content of " + virtPath + " with line numbers:\n" + numbered.join("\n")
101
+ }
102
+
103
+ function makeSnippet(content: string, editLine: number, virtPath: string) {
104
+ const lines = content.split("\n")
105
+ const radius = 4
106
+ const start = Math.max(0, editLine - 1 - radius)
107
+ const end = Math.min(lines.length, editLine - 1 + radius + 1)
108
+ const snippet = lines.slice(start, end)
109
+ const numbered = snippet.map((line, i) => fmtLine(start + i + 1) + "\t" + line)
110
+ return (
111
+ "The memory file has been edited. Here's the result of running `cat -n` on a snippet of " +
112
+ virtPath +
113
+ ":\n" +
114
+ numbered.join("\n")
115
+ )
116
+ }
117
+
118
+ async function viewFile(real: string, virtPath: string, range?: [number, number]) {
119
+ const stat = statSync(real, { throwIfNoEntry: false })
120
+ if (!stat) return "Error: path does not exist: " + virtPath
121
+ if (stat.isDirectory()) {
122
+ const entries = readdirSync(real, { withFileTypes: true })
123
+ const sorted = entries.sort((a, b) => {
124
+ if (a.isDirectory() && !b.isDirectory()) return -1
125
+ if (!a.isDirectory() && b.isDirectory()) return 1
126
+ return a.name.localeCompare(b.name)
127
+ })
128
+ const lines: string[] = []
129
+ for (const e of sorted) {
130
+ const subPath = path.join(real, e.name)
131
+ const size = e.isDirectory() ? 0 : statSync(subPath).size
132
+ lines.push(size + "\t" + (e.isDirectory() ? e.name + "/" : e.name))
133
+ if (e.isDirectory()) {
134
+ try {
135
+ const sub = readdirSync(subPath, { withFileTypes: true })
136
+ for (const se of sub.slice(0, 10)) {
137
+ const subSubPath = path.join(subPath, se.name)
138
+ const subSize = se.isDirectory() ? 0 : statSync(subSubPath).size
139
+ lines.push(subSize + "\t " + (se.isDirectory() ? se.name + "/" : se.name))
140
+ }
141
+ if (sub.length > 10) lines.push("0\t ... (" + (sub.length - 10) + " more)")
142
+ } catch {}
143
+ }
144
+ }
145
+ return lines.join("\n") || "(empty directory)"
146
+ }
147
+ const content = await fs.readFile(real, "utf8")
148
+ if (content === undefined) return "Error: could not read file: " + virtPath
149
+ if (!range) return formatFileContent(virtPath, content)
150
+ const lines = content.split("\n")
151
+ const [start, end] = range
152
+ if (start < 1 || start > lines.length)
153
+ return `Error: Invalid view_range: start line ${start} is out of range [1, ${lines.length}].`
154
+ if (end < start || end > lines.length)
155
+ return `Error: Invalid view_range: end line ${end} is out of range [${start}, ${lines.length}].`
156
+ const sliced = lines.slice(start - 1, end)
157
+ const numbered = sliced.map((line, i) => fmtLine(start + i) + "\t" + line)
158
+ return `Here's the content of ${virtPath} (lines ${start}-${end}) with line numbers:\n` + numbered.join("\n")
159
+ }
160
+
161
+ // ─── Memory context (for system prompt injection) ─────────────────────────────
162
+
163
+ const MAX_USER_MEMORY_LINES = 200
164
+
165
+ async function getUserMemoryContent(): Promise<string | undefined> {
166
+ const dir = userMemoryRoot()
167
+ if (!existsSync(dir)) return undefined
168
+ const entries = readdirSync(dir, { withFileTypes: true })
169
+ const files = entries.filter((e) => e.isFile() && !e.name.startsWith("."))
170
+ if (files.length === 0) return undefined
171
+ const lines: string[] = []
172
+ for (const f of files) {
173
+ if (lines.length >= MAX_USER_MEMORY_LINES) break
174
+ const content = await fs.readFile(path.join(dir, f.name), "utf8").catch(() => "")
175
+ if (content) lines.push("## " + f.name, ...content.split("\n"))
176
+ }
177
+ if (lines.length === 0) return undefined
178
+ return lines.slice(0, MAX_USER_MEMORY_LINES).join("\n")
179
+ }
180
+
181
+ function getSessionMemoryFiles(sessionID: string): string[] | undefined {
182
+ const dir = sessionMemoryRoot(sessionID)
183
+ if (!existsSync(dir)) return undefined
184
+ const entries = readdirSync(dir, { withFileTypes: true })
185
+ const files = entries
186
+ .filter((e) => e.isFile() && !e.name.startsWith("."))
187
+ .map((e) => "/memories/session/" + e.name)
188
+ return files.length > 0 ? files : undefined
189
+ }
190
+
191
+ function getRepoMemoryFiles(projectDir: string): string[] | undefined {
192
+ const dir = repoMemoryRoot(projectDir)
193
+ if (!existsSync(dir)) return undefined
194
+ const entries = readdirSync(dir, { withFileTypes: true })
195
+ const files = entries
196
+ .filter((e) => e.isFile() && !e.name.startsWith("."))
197
+ .map((e) => "/memories/repo/" + e.name)
198
+ return files.length > 0 ? files : undefined
199
+ }
200
+
201
+ async function buildMemoryContext(sessionID: string, projectDir: string): Promise<string> {
202
+ const userContent = await getUserMemoryContent()
203
+ const sessionFiles = getSessionMemoryFiles(sessionID)
204
+ const repoFiles = getRepoMemoryFiles(projectDir)
205
+
206
+ const context: string[] = []
207
+
208
+ context.push("<userMemory>")
209
+ if (userContent) {
210
+ context.push(
211
+ "The following are your persistent user memory notes. These persist across all projects and conversations.\n",
212
+ )
213
+ context.push(userContent)
214
+ } else {
215
+ context.push(
216
+ "No user preferences or notes saved yet. Use the memory tool to store persistent notes under /memories/.",
217
+ )
218
+ }
219
+ context.push("</userMemory>")
220
+
221
+ context.push("<sessionMemory>")
222
+ if (sessionFiles && sessionFiles.length > 0) {
223
+ context.push(
224
+ "The following files exist in your session memory (/memories/session/). Use the memory tool to read them if needed.\n",
225
+ )
226
+ context.push(sessionFiles.join("\n"))
227
+ } else {
228
+ context.push(
229
+ "Session memory (/memories/session/) is empty. No session notes have been created yet.",
230
+ )
231
+ }
232
+ context.push("</sessionMemory>")
233
+
234
+ context.push("<repoMemory>")
235
+ if (repoFiles && repoFiles.length > 0) {
236
+ context.push(
237
+ "The following files exist in your repository memory (/memories/repo/). These are scoped to the current project. Use the memory tool to read them if needed.\n",
238
+ )
239
+ context.push(repoFiles.join("\n"))
240
+ } else {
241
+ context.push(
242
+ "Repository memory (/memories/repo/) is empty. No project-scoped notes have been created yet.",
243
+ )
244
+ }
245
+ context.push("</repoMemory>")
246
+
247
+ return context.join("\n")
248
+ }
249
+
250
+ // ─── Tool description ─────────────────────────────────────────────────────────
251
+
252
+ const MEMORY_DESCRIPTION = `Manage a persistent memory system with three scopes for storing notes and information across conversations.
253
+
254
+ Memory is organized under /memories/ with three tiers:
255
+ - \`/memories/\` — User memory: global persistent notes shared across all projects in this environment. Store cross-project preferences, common patterns, and general insights here.
256
+ - \`/memories/session/\` — Session memory: notes scoped to the current conversation. Store task-specific context and in-progress notes here. Cleared after the conversation ends.
257
+ - \`/memories/repo/\` — Repository memory: project-scoped persistent notes stored in the project's .opencode/ directory. Store codebase conventions, architecture decisions, build commands, verified practices, and project-specific facts here. These persist across sessions and are specific to this project.
258
+
259
+ When to use each scope:
260
+ - Use /memories/repo/ for anything specific to the current project (architecture, conventions, gotchas, build steps)
261
+ - Use /memories/ for cross-project preferences (coding style, tool preferences, general patterns)
262
+ - Use /memories/session/ for temporary working state within the current conversation — keep plans and progress notes up to date here
263
+
264
+ Guidelines:
265
+ - Keep entries short and concise. Prefer multiple focused files over a single large file.
266
+ - Do NOT create unnecessary files. Only create memories when explicitly asked or when the information is clearly valuable for future interactions.
267
+ - Update or remove outdated memories rather than accumulating stale information.
268
+ - Before creating new memory files, first view the appropriate /memories/ directory to see what already exists — this helps avoid duplicates.
269
+ - You can have up to 200 lines per file. For longer content, split into multiple files.
270
+
271
+ Commands (all supported for all scopes):
272
+ - \`view\`: View contents of a file or list directory contents.
273
+ - \`create\`: Create a new file at the specified path with the given content. Fails if the file already exists.
274
+ - \`str_replace\`: Replace an exact string in a file with a new string. The old_str must appear exactly once in the file.
275
+ - \`insert\`: Insert text at a specific line number in a file. Line 0 inserts at the beginning.
276
+ - \`delete\`: Delete a file or directory (and all its contents).
277
+ - \`rename\`: Rename or move a file or directory from path to new_path. Cannot rename across scopes.`
278
+
279
+ // ─── Plugin entry ─────────────────────────────────────────────────────────────
280
+
281
+ export const plugin: Plugin = async (_ctx: { directory?: string }) => {
282
+ const projectDir = _ctx.directory ?? process.cwd()
283
+
284
+ function readConfig(): Record<string, string> {
285
+ try {
286
+ const p = path.join(configDir(), "execsa-config.json")
287
+ if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8"))
288
+ } catch {}
289
+ return {}
290
+ }
291
+
292
+ const cfg = readConfig()
293
+ const debugEnabled = cfg.debug_logging === "true"
294
+ const memoryEnabled = cfg.memory_tool_enabled !== "false"
295
+
296
+ if (!memoryEnabled) {
297
+ log("memory tool disabled via config")
298
+ return {}
299
+ }
300
+
301
+ function log(...args: any[]) {
302
+ if (!debugEnabled) return
303
+ console.error("[memory-plugin]", ...args)
304
+ }
305
+
306
+ const accessTimestamps = new Map<string, number>()
307
+ let cleanupStarted = false
308
+
309
+ function markAccessed(real: string) {
310
+ accessTimestamps.set(real, Date.now())
311
+ }
312
+
313
+ function isSessionPath(p: string): boolean {
314
+ return p.startsWith("/memories/session/") || p === "/memories/session"
315
+ }
316
+
317
+ function cleanupStaleSessionDirs() {
318
+ const sessionBase = path.join(configDir(), "memories", "session")
319
+ if (!existsSync(sessionBase)) return
320
+ const now = Date.now()
321
+ const entries = readdirSync(sessionBase, { withFileTypes: true })
322
+ let deleted = 0
323
+ for (const entry of entries) {
324
+ if (!entry.isDirectory()) continue
325
+ const dirPath = path.join(sessionBase, entry.name)
326
+ const lastAccess = accessTimestamps.get(dirPath) ?? statSync(dirPath).mtimeMs
327
+ if (now - lastAccess > RETENTION_MS) {
328
+ rmSync(dirPath, { recursive: true, force: true })
329
+ accessTimestamps.delete(dirPath)
330
+ deleted++
331
+ } else {
332
+ try {
333
+ const sub = readdirSync(dirPath)
334
+ if (sub.length === 0) {
335
+ rmSync(dirPath, { recursive: true, force: true })
336
+ accessTimestamps.delete(dirPath)
337
+ deleted++
338
+ }
339
+ } catch {}
340
+ }
341
+ }
342
+ if (deleted > 0) log(`cleaned ${deleted} stale session dirs`)
343
+ }
344
+
345
+ function startCleanup() {
346
+ if (cleanupStarted) return
347
+ cleanupStarted = true
348
+ setInterval(() => { try { cleanupStaleSessionDirs() } catch {} }, CLEANUP_INTERVAL_MS)
349
+ try { cleanupStaleSessionDirs() } catch {}
350
+ }
351
+
352
+ startCleanup()
353
+ log("plugin loaded")
354
+
355
+ return {
356
+ // ── Register the memory tool ──────────────────────────────────────────────
357
+ tool: {
358
+ memory: tool({
359
+ description: MEMORY_DESCRIPTION,
360
+ args: {
361
+ command: z
362
+ .enum(["view", "create", "str_replace", "insert", "delete", "rename"])
363
+ .describe("The operation to perform on the memory file system."),
364
+ path: z
365
+ .string()
366
+ .optional()
367
+ .describe(
368
+ 'The absolute path to the file or directory inside /memories/, e.g. "/memories/notes.md". Used by all commands except `rename`.',
369
+ ),
370
+ file_text: z
371
+ .string()
372
+ .optional()
373
+ .describe("Required for `create`. The content of the file to create."),
374
+ old_str: z
375
+ .string()
376
+ .optional()
377
+ .describe(
378
+ "Required for `str_replace`. The exact string in the file to replace. Must appear exactly once.",
379
+ ),
380
+ new_str: z
381
+ .string()
382
+ .optional()
383
+ .describe("Required for `str_replace`. The new string to replace old_str with."),
384
+ insert_line: z
385
+ .number()
386
+ .optional()
387
+ .describe(
388
+ "Required for `insert`. The 0-based line number to insert text at. 0 inserts before the first line.",
389
+ ),
390
+ insert_text: z
391
+ .string()
392
+ .optional()
393
+ .describe("Required for `insert`. The text to insert at the specified line."),
394
+ view_range: z
395
+ .tuple([z.number(), z.number()])
396
+ .optional()
397
+ .describe(
398
+ "Optional for `view`. A two-element array [start_line, end_line] (1-indexed) to view a specific range of lines.",
399
+ ),
400
+ old_path: z
401
+ .string()
402
+ .optional()
403
+ .describe("Required for `rename`. The current path of the file or directory to rename."),
404
+ new_path: z
405
+ .string()
406
+ .optional()
407
+ .describe("Required for `rename`. The new path for the file or directory."),
408
+ },
409
+
410
+ async execute(args: any, ctx: any) {
411
+ const sessionID: string = ctx.sessionID
412
+ const cmd: string = args.command
413
+
414
+ try {
415
+ switch (cmd) {
416
+ case "view": {
417
+ const p: string = args.path ?? "/memories/"
418
+ log("view", p)
419
+ const pathErr = validatePath(p)
420
+ if (pathErr) return pathErr
421
+ const { real } = resolvePath(p, sessionID, projectDir)
422
+ if (isSessionPath(p)) markAccessed(real)
423
+ ensure(path.dirname(real))
424
+ if (p === "/memories/" || p === "/memories") {
425
+ ensure(real)
426
+ const entries = readdirSync(real, { withFileTypes: true })
427
+ const lines = entries.map((e) => {
428
+ const size = e.isDirectory() ? 0 : statSync(path.join(real, e.name)).size
429
+ return size + "\t" + (e.isDirectory() ? e.name + "/" : e.name)
430
+ })
431
+ const repoDir = repoMemoryRoot(projectDir)
432
+ if (existsSync(repoDir)) {
433
+ const repoEntries = readdirSync(repoDir)
434
+ lines.push("0\trepo/ (" + repoEntries.length + " items, project-scoped)")
435
+ } else {
436
+ lines.push("0\trepo/ (empty, project-scoped)")
437
+ }
438
+ return lines.join("\n") || "(empty directory)"
439
+ }
440
+ return await viewFile(real, p, args.view_range as [number, number] | undefined)
441
+ }
442
+
443
+ case "create": {
444
+ log("create", args.path)
445
+ if (!args.path) return "Error: path is required for create"
446
+ if (args.file_text === undefined) return "Error: file_text is required for create"
447
+ const createPathErr = validatePath(args.path)
448
+ if (createPathErr) return createPathErr
449
+ const { real } = resolvePath(args.path, sessionID, projectDir)
450
+ if (isSessionPath(args.path)) markAccessed(real)
451
+ if (existsSync(real)) return "Error: file already exists at " + args.path
452
+ ensure(path.dirname(real))
453
+ await fs.writeFile(real, args.file_text, "utf8")
454
+ return "Successfully created " + args.path
455
+ }
456
+
457
+ case "str_replace": {
458
+ log("str_replace", args.path)
459
+ if (!args.path) return "Error: path is required for str_replace"
460
+ if (args.old_str === undefined) return "Error: old_str is required for str_replace"
461
+ if (args.new_str === undefined) return "Error: new_str is required for str_replace"
462
+ const strPathErr = validatePath(args.path)
463
+ if (strPathErr) return strPathErr
464
+ const { real: strReal } = resolvePath(args.path, sessionID, projectDir)
465
+ if (isSessionPath(args.path)) markAccessed(strReal)
466
+ let strContent: string
467
+ try {
468
+ strContent = await fs.readFile(strReal, "utf8")
469
+ } catch {
470
+ return "The path " + args.path + " does not exist. Please provide a valid path."
471
+ }
472
+ const occurrences: number[] = []
473
+ let searchStart = 0
474
+ while (true) {
475
+ const idx = strContent.indexOf(args.old_str, searchStart)
476
+ if (idx === -1) break
477
+ occurrences.push(strContent.substring(0, idx).split("\n").length)
478
+ searchStart = idx + 1
479
+ }
480
+ if (occurrences.length === 0) {
481
+ return (
482
+ "No replacement was performed, old_str `" +
483
+ args.old_str +
484
+ "` did not appear verbatim in " +
485
+ args.path +
486
+ "."
487
+ )
488
+ }
489
+ if (occurrences.length > 1) {
490
+ return (
491
+ "No replacement was performed. Multiple occurrences of old_str `" +
492
+ args.old_str +
493
+ "` in lines: " +
494
+ occurrences.join(", ") +
495
+ ". Please ensure it is unique."
496
+ )
497
+ }
498
+ const newContent = strContent.replace(args.old_str, args.new_str)
499
+ await fs.writeFile(strReal, newContent, "utf8")
500
+ return makeSnippet(newContent, occurrences[0], args.path)
501
+ }
502
+
503
+ case "insert": {
504
+ log("insert", args.path)
505
+ if (!args.path) return "Error: path is required for insert"
506
+ if (args.insert_line === undefined) return "Error: insert_line is required for insert"
507
+ const insertText = args.insert_text ?? args.new_str
508
+ if (!insertText) return "Error: Missing required insert_text parameter for insert."
509
+ const insPathErr = validatePath(args.path)
510
+ if (insPathErr) return insPathErr
511
+ const { real: insReal } = resolvePath(args.path, sessionID, projectDir)
512
+ if (isSessionPath(args.path)) markAccessed(insReal)
513
+ let insContent: string
514
+ try {
515
+ insContent = await fs.readFile(insReal, "utf8")
516
+ } catch {
517
+ return "Error: The path " + args.path + " does not exist"
518
+ }
519
+ const insLines = insContent.split("\n")
520
+ const nLines = insLines.length
521
+ if (args.insert_line < 0 || args.insert_line > nLines) {
522
+ return (
523
+ "Error: Invalid insert_line parameter: " +
524
+ args.insert_line +
525
+ ". It should be within the range [0, " +
526
+ nLines +
527
+ "]."
528
+ )
529
+ }
530
+ const newInsLines = insertText.split("\n")
531
+ insLines.splice(args.insert_line, 0, ...newInsLines)
532
+ const insResult = insLines.join("\n")
533
+ await fs.writeFile(insReal, insResult, "utf8")
534
+ return makeSnippet(insResult, args.insert_line + 1, args.path)
535
+ }
536
+
537
+ case "delete": {
538
+ log("delete", args.path)
539
+ if (!args.path) return "Error: path is required for delete"
540
+ const delPathErr = validatePath(args.path)
541
+ if (delPathErr) return delPathErr
542
+ const { real } = resolvePath(args.path, sessionID, projectDir)
543
+ if (isSessionPath(args.path)) markAccessed(path.dirname(real))
544
+ const stat = statSync(real, { throwIfNoEntry: false })
545
+ if (!stat) return "Error: path does not exist: " + args.path
546
+ await fs.rm(real, { recursive: true })
547
+ return "Successfully deleted " + args.path
548
+ }
549
+
550
+ case "rename": {
551
+ const oldPath = args.old_path ?? args.path
552
+ log("rename", oldPath, "->", args.new_path)
553
+ if (!oldPath) return "Error: old_path or path is required for rename"
554
+ if (!args.new_path) return "Error: new_path is required for rename"
555
+ const renOldErr = validatePath(oldPath)
556
+ if (renOldErr) return renOldErr
557
+ const renNewErr = validatePath(args.new_path)
558
+ if (renNewErr) return renNewErr
559
+ const from = resolvePath(oldPath, sessionID, projectDir)
560
+ const to = resolvePath(args.new_path, sessionID, projectDir)
561
+ if (from.scope !== to.scope)
562
+ return "Error: Cannot rename across different memory scopes."
563
+ if (isSessionPath(oldPath)) markAccessed(from.real)
564
+ if (isSessionPath(args.new_path)) markAccessed(to.real)
565
+ const fromStat = statSync(from.real, { throwIfNoEntry: false })
566
+ if (!fromStat) return "Error: The path " + oldPath + " does not exist"
567
+ const toStat = statSync(to.real, { throwIfNoEntry: false })
568
+ if (toStat) return "Error: The destination " + args.new_path + " already exists"
569
+ ensure(path.dirname(to.real))
570
+ await fs.rename(from.real, to.real)
571
+ return "Successfully renamed"
572
+ }
573
+
574
+ default:
575
+ log("unknown command", cmd)
576
+ return "Error: unknown command: " + cmd
577
+ }
578
+ } catch (e: any) {
579
+ log("execute error", e?.message ?? String(e))
580
+ return "Error: " + (e?.message ?? String(e))
581
+ }
582
+ },
583
+ }),
584
+ },
585
+
586
+ // ── Inject memory context into system prompt ──────────────────────────────
587
+ "experimental.chat.system.transform": async (
588
+ input: { sessionID?: string; model?: any },
589
+ output: { system: string[] },
590
+ ) => {
591
+ const sessionID = input.sessionID
592
+ if (!sessionID) return
593
+
594
+ // Skip memory context injection for execsa subagent — it doesn't need project memories.
595
+ if (output.system.some((s) => s.includes("execution-focused subagent"))) return
596
+
597
+ const memCtx = await buildMemoryContext(sessionID, projectDir)
598
+ if (memCtx) {
599
+ output.system.push(memCtx)
600
+ }
601
+ },
602
+ }
603
+ }
604
+
605
+ export default plugin
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "opencode-memory-tool",
3
+ "version": "1.0.0",
4
+ "description": "Persistent memory tool plugin for OpenCode — adds /memories/ scoped storage across user, session, and repo tiers",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "files": ["index.ts", "README.md"],
8
+ "repository": {"type": "git", "url": "git+https://github.com/lkonga/opencode-memory.git"},
9
+ "publishConfig": {"access": "public"},
10
+ "dependencies": {
11
+ "@opencode-ai/plugin": "*",
12
+ "zod": "^4.3.6"
13
+ },
14
+ "devDependencies": {
15
+ "@types/node": "^22.0.0",
16
+ "typescript": "^5.0.0"
17
+ }
18
+ }