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.
- package/README.md +54 -0
- package/index.ts +605 -0
- 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
|
+
}
|