opencode-memory 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # Simple Memory Plugin for OpenCode
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@knikolov/opencode-plugin-simple-memory)](https://www.npmjs.com/package/@knikolov/opencode-plugin-simple-memory)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ A persistent memory plugin for [OpenCode](https://opencode.ai) that enables the AI assistant to remember context across sessions.
7
+
8
+ ## Setup
9
+
10
+ 1. Add the plugin to your [OpenCode config](https://opencode.ai/docs/config/):
11
+
12
+ ```json
13
+ {
14
+ "$schema": "https://opencode.ai/config.json",
15
+ "plugin": ["@knikolov/opencode-plugin-simple-memory"]
16
+ }
17
+ ```
18
+
19
+ 2. Start using memory commands in your conversations.
20
+
21
+ Memories are stored in `.opencode/memory/` as daily logfmt files.
22
+
23
+ ## Updating
24
+
25
+ > [!WARNING]
26
+ > OpenCode does NOT auto-update plugins.
27
+
28
+ To get the latest version, clear the cached plugin and let OpenCode reinstall it:
29
+
30
+ ```bash
31
+ # Remove the plugin from cache
32
+ rm -rf ~/.cache/opencode/node_modules/@knikolov/opencode-plugin-simple-memory
33
+
34
+ # Run OpenCode to trigger reinstall
35
+ opencode
36
+ ```
37
+
38
+ ## Tools
39
+
40
+ The plugin provides five tools:
41
+
42
+ | Tool | Description |
43
+ |------|-------------|
44
+ | `memory_remember` | Store a new memory |
45
+ | `memory_recall` | Retrieve memories by scope, type, or search query |
46
+ | `memory_update` | Update an existing memory |
47
+ | `memory_forget` | Delete a memory (with audit logging) |
48
+ | `memory_list` | List all scopes and types for discovery |
49
+
50
+ ## Memory Types
51
+
52
+ - `decision` - Architectural or design decisions
53
+ - `learning` - Things learned during development
54
+ - `preference` - User or project preferences
55
+ - `blocker` - Current blockers or issues
56
+ - `context` - General context information
57
+ - `pattern` - Recurring patterns or conventions
58
+
59
+ ## Example Usage
60
+
61
+ ```
62
+ User: Remember that my name is Kris
63
+
64
+ AI: [calls memory_remember with type="context", scope="user",
65
+ content="User's name is Kris"]
66
+
67
+ I've stored that your name is Kris.
68
+
69
+ User: My preferred programming language is Erlang
70
+
71
+ AI: [calls memory_remember with type="preference", scope="user",
72
+ content="Preferred programming language is Erlang"]
73
+
74
+ Got it! I've noted that your preferred programming language is Erlang.
75
+
76
+ User: What do you know about me?
77
+
78
+ AI: [calls memory_recall with scope="user"]
79
+
80
+ Here's what I know about you:
81
+ - Your name is Kris
82
+ - Your preferred programming language is Erlang
83
+
84
+ User: Actually, I prefer Elixir now.
85
+
86
+ AI: [calls memory_update with type="preference", scope="user",
87
+ content="Preferred programming language is Elixir"]
88
+
89
+ Updated your language preference to Elixir.
90
+
91
+ User: Forget my language preference
92
+
93
+ AI: [calls memory_forget with type="preference", scope="user",
94
+ reason="User requested removal"]
95
+
96
+ Done. I've removed your language preference from memory.
97
+ ```
98
+
99
+ ## Local Development
100
+
101
+ Clone the repository and install dependencies:
102
+
103
+ ```bash
104
+ git clone https://github.com/cnicolov/opencode-plugin-simple-memory.git
105
+ cd opencode-plugin-simple-memory
106
+ bun install
107
+ ```
108
+
109
+ Point your OpenCode config to the local checkout via a `file://` URL:
110
+
111
+ ```json
112
+ {
113
+ "$schema": "https://opencode.ai/config.json",
114
+ "plugin": ["file:///absolute/path/to/opencode-plugin-simple-memory"]
115
+ }
116
+ ```
117
+
118
+ Replace `/absolute/path/to/opencode-plugin-simple-memory` with your actual path.
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { MemoryPlugin } from "./src/index";
2
+ export { MemoryPlugin as default } from "./src/index";
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "opencode-memory",
3
+ "module": "index.ts",
4
+ "version": "1.0.0",
5
+ "author": "knikolov",
6
+ "repository": "https://github.com/shuans/opencode-memory",
7
+ "files": [
8
+ "index.ts",
9
+ "src"
10
+ ],
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "devDependencies": {
14
+ "@opencode-ai/plugin": "^1.0.153",
15
+ "@types/bun": "latest"
16
+ },
17
+ "peerDependencies": {
18
+ "typescript": "^5"
19
+ }
20
+ }
package/src/index.ts ADDED
@@ -0,0 +1,359 @@
1
+ import { type Plugin, tool } from "@opencode-ai/plugin"
2
+
3
+ const MEMORY_DIR = "~/.config/opencode/memory"
4
+
5
+ const getMemoryFile = () => {
6
+ const date = new Date().toISOString().split("T")[0]
7
+ return Bun.file(`${MEMORY_DIR}/${date}.logfmt`)
8
+ }
9
+
10
+ const ensureDir = async () => {
11
+ const dir = Bun.file(MEMORY_DIR)
12
+ if (!(await dir.exists())) {
13
+ await Bun.$`mkdir -p ${MEMORY_DIR}`
14
+ }
15
+ }
16
+
17
+ interface Memory {
18
+ ts: string
19
+ type: string
20
+ scope: string
21
+ content: string
22
+ issue?: string
23
+ tags?: string[]
24
+ }
25
+
26
+ const parseLine = (line: string): Memory | null => {
27
+ const tsMatch = line.match(/ts=([^\s]+)/)
28
+ const typeMatch = line.match(/type=([^\s]+)/)
29
+ const scopeMatch = line.match(/scope=([^\s]+)/)
30
+ const contentMatch = line.match(/content="([^"]*(?:\\"[^"]*)*)"/)
31
+ const issueMatch = line.match(/issue=([^\s]+)/)
32
+ const tagsMatch = line.match(/tags=([^\s]+)/)
33
+
34
+ if (!tsMatch?.[1] || !typeMatch?.[1] || !scopeMatch?.[1]) return null
35
+
36
+ return {
37
+ ts: tsMatch[1],
38
+ type: typeMatch[1],
39
+ scope: scopeMatch[1],
40
+ content: contentMatch?.[1]?.replace(/\\"/g, '"') || "",
41
+ issue: issueMatch?.[1],
42
+ tags: tagsMatch?.[1]?.split(","),
43
+ }
44
+ }
45
+
46
+ const formatMemory = (m: Memory): string => {
47
+ const date = m.ts.split("T")[0]
48
+ const tags = m.tags?.length ? ` [${m.tags.join(", ")}]` : ""
49
+ const issue = m.issue ? ` (${m.issue})` : ""
50
+ return `[${date}] ${m.type}/${m.scope}: ${m.content}${issue}${tags}`
51
+ }
52
+
53
+ const scoreMatch = (memory: Memory, words: string[]): number => {
54
+ const searchable = `${memory.type} ${memory.scope} ${memory.content} ${memory.tags?.join(" ") || ""}`.toLowerCase()
55
+ let score = 0
56
+ for (const word of words) {
57
+ if (searchable.includes(word)) score++
58
+ if (memory.scope.toLowerCase() === word) score += 2
59
+ if (memory.type.toLowerCase() === word) score += 2
60
+ }
61
+ return score
62
+ }
63
+
64
+ const remember = tool({
65
+ description: "Store a memory (decision, learning, preference, blocker, context, pattern)",
66
+ args: {
67
+ type: tool.schema
68
+ .enum(["decision", "learning", "preference", "blocker", "context", "pattern"])
69
+ .describe("Type of memory"),
70
+ scope: tool.schema.string().describe("Scope/area (e.g., auth, api, mobile)"),
71
+ content: tool.schema.string().describe("The memory content"),
72
+ issue: tool.schema.string().optional().describe("Related GitHub issue (e.g., #51)"),
73
+ tags: tool.schema.array(tool.schema.string()).optional().describe("Additional tags"),
74
+ },
75
+ async execute(args) {
76
+ await ensureDir()
77
+
78
+ const ts = new Date().toISOString()
79
+ const issue = args.issue ? ` issue=${args.issue}` : ""
80
+ const tags = args.tags?.length ? ` tags=${args.tags.join(",")}` : ""
81
+ const content = args.content.replace(/"/g, '\\"')
82
+ const line = `ts=${ts} type=${args.type} scope=${args.scope} content="${content}"${issue}${tags}\n`
83
+
84
+ const file = getMemoryFile()
85
+ const existing = (await file.exists()) ? await file.text() : ""
86
+ await Bun.write(file, existing + line)
87
+
88
+ return `Remembered: ${args.type} in ${args.scope}`
89
+ },
90
+ })
91
+
92
+ const getAllMemories = async (): Promise<Memory[]> => {
93
+ const glob = new Bun.Glob("*.logfmt")
94
+ const files = await Array.fromAsync(glob.scan(MEMORY_DIR))
95
+
96
+ if (!files.length) return []
97
+
98
+ const lines: string[] = []
99
+ for (const filename of files) {
100
+ if (filename === "deletions.logfmt") continue // skip audit log
101
+ const file = Bun.file(`${MEMORY_DIR}/${filename}`)
102
+ const text = await file.text()
103
+ lines.push(...text.trim().split("\n").filter(Boolean))
104
+ }
105
+
106
+ return lines.map(parseLine).filter((m): m is Memory => m !== null)
107
+ }
108
+
109
+ const logDeletion = async (memory: Memory, reason: string) => {
110
+ await ensureDir()
111
+ const ts = new Date().toISOString()
112
+ const content = memory.content.replace(/"/g, '\\"')
113
+ const originalTs = memory.ts
114
+ const issue = memory.issue ? ` issue=${memory.issue}` : ""
115
+ const tags = memory.tags?.length ? ` tags=${memory.tags.join(",")}` : ""
116
+ const escapedReason = reason.replace(/"/g, '\\"')
117
+ const line = `ts=${ts} action=deleted original_ts=${originalTs} type=${memory.type} scope=${memory.scope} content="${content}" reason="${escapedReason}"${issue}${tags}\n`
118
+
119
+ const file = Bun.file(`${MEMORY_DIR}/deletions.logfmt`)
120
+ const existing = (await file.exists()) ? await file.text() : ""
121
+ await Bun.write(file, existing + line)
122
+ }
123
+
124
+ const recall = tool({
125
+ description: "Retrieve memories by scope, type, or search query",
126
+ args: {
127
+ scope: tool.schema.string().optional().describe("Filter by scope"),
128
+ type: tool.schema
129
+ .enum(["decision", "learning", "preference", "blocker", "context", "pattern"])
130
+ .optional()
131
+ .describe("Filter by type"),
132
+ query: tool.schema.string().optional().describe("Search term (space-separated words, matches any)"),
133
+ limit: tool.schema.number().optional().describe("Max results (default 20)"),
134
+ },
135
+ async execute(args) {
136
+ let results = await getAllMemories()
137
+
138
+ if (!results.length) return "No memories found"
139
+
140
+ const totalCount = results.length
141
+
142
+ if (args.scope) {
143
+ results = results.filter((m) => m.scope === args.scope || m.scope.includes(args.scope!))
144
+ }
145
+ if (args.type) {
146
+ results = results.filter((m) => m.type === args.type)
147
+ }
148
+
149
+ if (args.query) {
150
+ const words = args.query.toLowerCase().split(/\s+/).filter(Boolean)
151
+ const scored = results
152
+ .map((m) => ({ memory: m, score: scoreMatch(m, words) }))
153
+ .filter((x) => x.score > 0)
154
+ .sort((a, b) => b.score - a.score)
155
+ results = scored.map((x) => x.memory)
156
+ }
157
+
158
+ const filteredCount = results.length
159
+ const limit = args.limit || 20
160
+ const limited = results.slice(-limit)
161
+
162
+ if (!limited.length) return "No matching memories"
163
+
164
+ const header = filteredCount > limit
165
+ ? `Found ${filteredCount} memories (showing last ${limit} of ${totalCount} total)\n\n`
166
+ : filteredCount !== totalCount
167
+ ? `Found ${filteredCount} memories (${totalCount} total)\n\n`
168
+ : `Found ${filteredCount} memories\n\n`
169
+
170
+ return header + limited.map(formatMemory).join("\n")
171
+ },
172
+ })
173
+
174
+ const update = tool({
175
+ description: "Update an existing memory by scope and type (finds matching memory and updates its content)",
176
+ args: {
177
+ scope: tool.schema.string().describe("Scope of memory to update"),
178
+ type: tool.schema
179
+ .enum(["decision", "learning", "preference", "blocker", "context", "pattern"])
180
+ .describe("Type of memory"),
181
+ content: tool.schema.string().describe("The new content for the memory"),
182
+ query: tool.schema.string().optional().describe("Search term to find specific memory if multiple exist"),
183
+ issue: tool.schema.string().optional().describe("Update related GitHub issue (e.g., #51)"),
184
+ tags: tool.schema.array(tool.schema.string()).optional().describe("Update tags"),
185
+ },
186
+ async execute(args) {
187
+ const glob = new Bun.Glob("*.logfmt")
188
+ const files = await Array.fromAsync(glob.scan(MEMORY_DIR))
189
+
190
+ if (!files.length) return "No memory files found"
191
+
192
+ // Find matching memories
193
+ const matches: { memory: Memory; filepath: string; lineIndex: number }[] = []
194
+
195
+ for (const filename of files) {
196
+ if (filename === "deletions.logfmt") continue
197
+ const filepath = `${MEMORY_DIR}/${filename}`
198
+ const file = Bun.file(filepath)
199
+ const text = await file.text()
200
+ const lines = text.split("\n")
201
+
202
+ lines.forEach((line, lineIndex) => {
203
+ const memory = parseLine(line)
204
+ if (!memory) return
205
+ if (memory.scope === args.scope && memory.type === args.type) {
206
+ matches.push({ memory, filepath, lineIndex })
207
+ }
208
+ })
209
+ }
210
+
211
+ if (matches.length === 0) {
212
+ return `No memories found for ${args.type} in ${args.scope}`
213
+ }
214
+
215
+ // If multiple matches and query provided, filter by query
216
+ let target: typeof matches[number] | undefined = matches[0]
217
+ if (matches.length > 1) {
218
+ if (args.query) {
219
+ const words = args.query.toLowerCase().split(/\s+/).filter(Boolean)
220
+ const scored = matches
221
+ .map((m) => ({ ...m, score: scoreMatch(m.memory, words) }))
222
+ .filter((x) => x.score > 0)
223
+ .sort((a, b) => b.score - a.score)
224
+
225
+ if (scored.length === 0) {
226
+ return `Found ${matches.length} memories for ${args.type}/${args.scope}, but none matched query "${args.query}". Use recall to see all matches.`
227
+ }
228
+ target = scored[0]
229
+ } else {
230
+ return `Found ${matches.length} memories for ${args.type}/${args.scope}. Provide a query to select which one to update, or use recall to see all matches.`
231
+ }
232
+ }
233
+
234
+ if (!target) {
235
+ return `No memories found for ${args.type} in ${args.scope}`
236
+ }
237
+
238
+ // Log the old version before updating
239
+ await logDeletion(target.memory, `Updated to: ${args.content}`)
240
+
241
+ // Update the memory
242
+ const file = Bun.file(target.filepath)
243
+ const text = await file.text()
244
+ const lines = text.split("\n")
245
+
246
+ const ts = new Date().toISOString()
247
+ const issue = args.issue !== undefined ? args.issue : target.memory.issue
248
+ const tags = args.tags !== undefined ? args.tags : target.memory.tags
249
+ const issueStr = issue ? ` issue=${issue}` : ""
250
+ const tagsStr = tags?.length ? ` tags=${tags.join(",")}` : ""
251
+ const content = args.content.replace(/"/g, '\\"')
252
+ const newLine = `ts=${ts} type=${args.type} scope=${args.scope} content="${content}"${issueStr}${tagsStr}`
253
+
254
+ lines[target.lineIndex] = newLine
255
+ await Bun.write(target.filepath, lines.join("\n"))
256
+
257
+ return `Updated ${args.type} in ${args.scope}: "${args.content}"`
258
+ },
259
+ })
260
+
261
+ const listMemories = tool({
262
+ description: "List all unique scopes and types in memory for discovery",
263
+ args: {},
264
+ async execute() {
265
+ const memories = await getAllMemories()
266
+
267
+ if (!memories.length) return "No memories found"
268
+
269
+ const scopes = new Map<string, number>()
270
+ const types = new Map<string, number>()
271
+ const scopeTypes = new Map<string, Set<string>>()
272
+
273
+ for (const m of memories) {
274
+ scopes.set(m.scope, (scopes.get(m.scope) || 0) + 1)
275
+ types.set(m.type, (types.get(m.type) || 0) + 1)
276
+ if (!scopeTypes.has(m.scope)) scopeTypes.set(m.scope, new Set())
277
+ scopeTypes.get(m.scope)!.add(m.type)
278
+ }
279
+
280
+ const lines: string[] = []
281
+ lines.push(`Total memories: ${memories.length}`)
282
+ lines.push("")
283
+ lines.push("Scopes:")
284
+ for (const [scope, count] of [...scopes.entries()].sort((a, b) => b[1] - a[1])) {
285
+ const typeList = [...scopeTypes.get(scope)!].join(", ")
286
+ lines.push(` ${scope}: ${count} (${typeList})`)
287
+ }
288
+ lines.push("")
289
+ lines.push("Types:")
290
+ for (const [type, count] of [...types.entries()].sort((a, b) => b[1] - a[1])) {
291
+ lines.push(` ${type}: ${count}`)
292
+ }
293
+
294
+ return lines.join("\n")
295
+ },
296
+ })
297
+
298
+ const forget = tool({
299
+ description: "Delete a memory by scope and type (removes matching lines from all memory files, logs deletion for audit)",
300
+ args: {
301
+ scope: tool.schema.string().describe("Scope of memory to delete"),
302
+ type: tool.schema
303
+ .enum(["decision", "learning", "preference", "blocker", "context", "pattern"])
304
+ .describe("Type of memory"),
305
+ reason: tool.schema.string().describe("Why this is being deleted (for audit purposes)"),
306
+ },
307
+ async execute(args) {
308
+ const glob = new Bun.Glob("*.logfmt")
309
+ const files = await Array.fromAsync(glob.scan(MEMORY_DIR))
310
+
311
+ if (!files.length) return "No memory files found"
312
+
313
+ let deleted = 0
314
+ const deletedMemories: Memory[] = []
315
+
316
+ for (const filename of files) {
317
+ if (filename === "deletions.logfmt") continue // skip audit log
318
+ const filepath = `${MEMORY_DIR}/${filename}`
319
+ const file = Bun.file(filepath)
320
+ const text = await file.text()
321
+ const lines = text.split("\n")
322
+ const filtered = lines.filter((line) => {
323
+ const memory = parseLine(line)
324
+ if (!memory) return true
325
+ if (memory.scope === args.scope && memory.type === args.type) {
326
+ deleted++
327
+ deletedMemories.push(memory)
328
+ return false
329
+ }
330
+ return true
331
+ })
332
+ if (filtered.length !== lines.length) {
333
+ await Bun.write(filepath, filtered.join("\n"))
334
+ }
335
+ }
336
+
337
+ // Log all deletions to audit file
338
+ for (const memory of deletedMemories) {
339
+ await logDeletion(memory, args.reason)
340
+ }
341
+
342
+ if (deleted === 0) return `No memories found for ${args.type} in ${args.scope}`
343
+ return `Deleted ${deleted} ${args.type} memory(s) from ${args.scope}. Reason: ${args.reason}\nDeletions logged to ${MEMORY_DIR}/deletions.logfmt`
344
+ },
345
+ })
346
+
347
+ export const MemoryPlugin: Plugin = async (_ctx) => {
348
+ return {
349
+ tool: {
350
+ memory_remember: remember,
351
+ memory_recall: recall,
352
+ memory_update: update,
353
+ memory_forget: forget,
354
+ memory_list: listMemories,
355
+ },
356
+ }
357
+ }
358
+
359
+ export default MemoryPlugin