@knikolov/opencode-plugin-simple-memory 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +144 -2
  2. package/package.json +18 -3
  3. package/src/index.ts +687 -271
package/README.md CHANGED
@@ -18,7 +18,13 @@ A persistent memory plugin for [OpenCode](https://opencode.ai) that enables the
18
18
 
19
19
  2. Start using memory commands in your conversations.
20
20
 
21
- Memories are stored in `.opencode/memory/` as daily logfmt files.
21
+ Memories are stored in `.opencode/memory/` as daily logfmt files. Existing logfmt files remain readable across plugin updates.
22
+
23
+ Automatic memory loading and saving are opt-in. When enabled, the plugin can load and save context automatically:
24
+
25
+ - Before a response, it injects a short relevant-memory block based on the latest user message.
26
+ - When the user explicitly says “remember ...”, it saves that memory automatically.
27
+ - It does not automatically save arbitrary conversation content.
22
28
 
23
29
  ## Updating
24
30
 
@@ -37,7 +43,7 @@ opencode
37
43
 
38
44
  ## Tools
39
45
 
40
- The plugin provides five tools:
46
+ The plugin provides nine tools:
41
47
 
42
48
  | Tool | Description |
43
49
  |------|-------------|
@@ -46,6 +52,10 @@ The plugin provides five tools:
46
52
  | `memory_update` | Update an existing memory |
47
53
  | `memory_forget` | Delete a memory (with audit logging) |
48
54
  | `memory_list` | List all scopes and types for discovery |
55
+ | `memory_export` | Export memories as `jsonl`, `json`, or `logfmt` |
56
+ | `memory_import` | Import memories from `jsonl`, `json`, or compatible `logfmt` |
57
+ | `memory_compact` | Rewrite memory files chronologically and remove exact duplicates |
58
+ | `memory_context` | Build a compact relevant-memory context pack |
49
59
 
50
60
  ## Memory Types
51
61
 
@@ -96,6 +106,131 @@ AI: [calls memory_forget with type="preference", scope="user",
96
106
  Done. I've removed your language preference from memory.
97
107
  ```
98
108
 
109
+ ## Recall Filters
110
+
111
+ `memory_recall` supports the original filters plus optional richer filters:
112
+
113
+ - `scope` - filter by scope. By default this keeps the original contains-style matching.
114
+ - `type` - filter by memory type.
115
+ - `query` - rank by matching words across type, scope, content, and tags.
116
+ - `limit` - maximum results. Query results return the best matches; non-query results return the latest memories.
117
+ - `tags` - require all provided tags.
118
+ - `since` / `until` - filter by ISO timestamp or date prefix.
119
+ - `match` - scope matching mode: `contains`, `exact`, or `prefix`.
120
+
121
+ ## Automatic Context
122
+
123
+ Automatic context loading is disabled by default. When `autoLoad` is enabled, the plugin uses OpenCode chat hooks to remember the latest user message, search active memories, and inject a compact block like this into system context:
124
+
125
+ ```md
126
+ Relevant Memory:
127
+ - context/deploy/staging: Use materialize-deployments.cjs for staging runtime restart
128
+ - context/tests: Run make staging-live-onboarding-e2e for staging onboarding
129
+ ```
130
+
131
+ Automatic context saving is also disabled by default. When `autoSave` is enabled, it is intentionally conservative and only stores explicit requests such as:
132
+
133
+ ```text
134
+ remember that I prefer minimal diffs
135
+ ```
136
+
137
+ That request is stored as a `preference` memory in scope `user` with tag `auto`. Other explicit remember requests default to `context/user` unless they look like a decision, blocker, pattern, or preference.
138
+
139
+ Configure the behavior through plugin options by using OpenCode's plugin tuple form. The first item is the package name and the second item is the options object passed to the plugin:
140
+
141
+ ```json
142
+ {
143
+ "$schema": "https://opencode.ai/config.json",
144
+ "plugin": [
145
+ [
146
+ "@knikolov/opencode-plugin-simple-memory",
147
+ {
148
+ "autoLoad": true,
149
+ "autoSave": true,
150
+ "autoHookTimeoutMs": 100,
151
+ "contextLimit": 5,
152
+ "contextMaxChars": 1200,
153
+ "contextMinScore": 1,
154
+ "autoSaveScope": "user"
155
+ }
156
+ ]
157
+ ]
158
+ }
159
+ ```
160
+
161
+ Configuration options:
162
+
163
+ | Option | Default | Description |
164
+ |--------|---------|-------------|
165
+ | `autoLoad` | `false` | Enables automatic relevant-memory injection before responses. The plugin uses the latest user message as the search query and appends a compact `Relevant Memory:` block to system context when matches exist. |
166
+ | `autoSave` | `false` | Enables automatic saving only for explicit user requests like `remember that I prefer minimal diffs`. It does not save arbitrary conversation content. |
167
+ | `autoHookTimeoutMs` | `100` | Maximum time each automatic hook can spend on memory work. Hooks fail open after this timeout so memory loading or saving cannot block normal responses. |
168
+ | `contextLimit` | `5` | Maximum number of memories included in the automatic relevant-memory block. |
169
+ | `contextMaxChars` | `1200` | Maximum character budget for the automatic relevant-memory block. Matching memories are truncated to stay within this budget. |
170
+ | `contextMinScore` | `1` when there is a query | Minimum relevance score required for automatic context loading. Increase this to make injected memory stricter; set it lower to include weaker matches. |
171
+ | `autoSaveScope` | `"user"` | Scope used for automatic explicit `remember ...` saves unless the inferred memory itself provides something more specific. |
172
+
173
+ To keep automatic behavior disabled while retaining manual tools, omit options entirely or set both flags to `false`:
174
+
175
+ ```json
176
+ {
177
+ "$schema": "https://opencode.ai/config.json",
178
+ "plugin": [
179
+ [
180
+ "@knikolov/opencode-plugin-simple-memory",
181
+ {
182
+ "autoLoad": false,
183
+ "autoSave": false
184
+ }
185
+ ]
186
+ ]
187
+ }
188
+ ```
189
+
190
+ For local development, point OpenCode at the checkout with a `file://` URL and pass the same options:
191
+
192
+ ```json
193
+ {
194
+ "$schema": "https://opencode.ai/config.json",
195
+ "plugin": [
196
+ [
197
+ "file:///absolute/path/to/opencode-plugin-simple-memory/index.ts",
198
+ {
199
+ "autoLoad": true,
200
+ "autoSave": true
201
+ }
202
+ ]
203
+ ]
204
+ }
205
+ ```
206
+
207
+ OpenCode loads plugin configuration at startup. Restart OpenCode after changing configuration.
208
+
209
+ ## Storage Format
210
+
211
+ Memory files are daily logfmt files named `YYYY-MM-DD.logfmt` under `.opencode/memory/`.
212
+
213
+ Each active memory record uses these fields:
214
+
215
+ ```logfmt
216
+ ts=2026-05-28T10:00:00.000Z type=context scope=api content="Remember this" issue=#51 tags=backend,current
217
+ ```
218
+
219
+ Compatibility notes:
220
+
221
+ - Existing unquoted `scope`, `issue`, and `tags` records remain readable.
222
+ - New records quote fields only when needed, except `content`, which is always quoted.
223
+ - Multiline content is stored on one physical line using escaped `\n` sequences and is restored during recall/export.
224
+ - Updates and deletes append audit records to `.opencode/memory/deletions.logfmt`.
225
+
226
+ ## Maintenance
227
+
228
+ `memory_forget` keeps its original behavior when called with only `scope`, `type`, and `reason`: it deletes all exact matches. To delete only one matching memory, pass `query`.
229
+
230
+ `memory_export` and `memory_import` can move memories between projects or back up the store. `jsonl` is the default export/import format.
231
+
232
+ `memory_compact` removes exact duplicate active records and rewrites active memory files in chronological order. Use `dryRun: true` to preview the change.
233
+
99
234
  ## Local Development
100
235
 
101
236
  Clone the repository and install dependencies:
@@ -106,6 +241,13 @@ cd opencode-plugin-simple-memory
106
241
  bun install
107
242
  ```
108
243
 
244
+ Run checks:
245
+
246
+ ```bash
247
+ bun test
248
+ bun run typecheck
249
+ ```
250
+
109
251
  Point your OpenCode config to the local checkout via a `file://` URL:
110
252
 
111
253
  ```json
package/package.json CHANGED
@@ -1,17 +1,32 @@
1
1
  {
2
2
  "name": "@knikolov/opencode-plugin-simple-memory",
3
3
  "module": "index.ts",
4
- "version": "1.0.4",
4
+ "version": "1.1.1",
5
+ "description": "Persistent project memory tools for OpenCode",
5
6
  "author": "knikolov",
6
7
  "repository": "https://github.com/cnicolov/opencode-plugin-simple-memory",
8
+ "homepage": "https://github.com/cnicolov/opencode-plugin-simple-memory#readme",
9
+ "bugs": "https://github.com/cnicolov/opencode-plugin-simple-memory/issues",
10
+ "keywords": [
11
+ "opencode",
12
+ "opencode-plugin",
13
+ "memory",
14
+ "agent"
15
+ ],
16
+ "scripts": {
17
+ "test": "bun test",
18
+ "typecheck": "bun run tsc --noEmit"
19
+ },
7
20
  "files": [
8
21
  "index.ts",
9
- "src"
22
+ "src/index.ts"
10
23
  ],
11
24
  "license": "MIT",
12
25
  "type": "module",
26
+ "dependencies": {
27
+ "@opencode-ai/plugin": "^1.0.153"
28
+ },
13
29
  "devDependencies": {
14
- "@opencode-ai/plugin": "^1.0.153",
15
30
  "@types/bun": "latest"
16
31
  },
17
32
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -1,53 +1,186 @@
1
1
  import { type Plugin, tool } from "@opencode-ai/plugin"
2
+ import { appendFile, mkdir, rename } from "node:fs/promises"
3
+ import { join } from "node:path"
2
4
 
3
- let MEMORY_DIR = ".opencode/memory"
5
+ const MEMORY_TYPES = ["decision", "learning", "preference", "blocker", "context", "pattern"] as const
4
6
 
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
- }
7
+ type MemoryType = typeof MEMORY_TYPES[number]
16
8
 
17
9
  interface Memory {
18
10
  ts: string
19
- type: string
11
+ type: MemoryType
20
12
  scope: string
21
13
  content: string
22
14
  issue?: string
23
15
  tags?: string[]
24
16
  }
25
17
 
18
+ interface MemoryEntry {
19
+ memory: Memory
20
+ filepath: string
21
+ lineIndex: number
22
+ }
23
+
24
+ interface MemoryStore {
25
+ dir: string
26
+ ensureDir(): Promise<void>
27
+ appendMemory(memory: Memory): Promise<void>
28
+ appendDeletion(memory: Memory, reason: string): Promise<void>
29
+ readEntries(): Promise<MemoryEntry[]>
30
+ readDeletionLines(): Promise<string[]>
31
+ rewriteFile(filepath: string, lines: string[]): Promise<void>
32
+ }
33
+
34
+ interface PluginOptions {
35
+ autoLoad?: boolean
36
+ autoSave?: boolean
37
+ autoHookTimeoutMs?: number
38
+ contextLimit?: number
39
+ contextMaxChars?: number
40
+ contextMinScore?: number
41
+ autoSaveScope?: string
42
+ }
43
+
44
+ interface ContextOptions {
45
+ query?: string
46
+ scope?: string
47
+ tags?: string[]
48
+ types?: MemoryType[]
49
+ limit?: number
50
+ maxChars?: number
51
+ minScore?: number
52
+ }
53
+
54
+ const isMemoryType = (value: string): value is MemoryType => MEMORY_TYPES.includes(value as MemoryType)
55
+
56
+ const dateFromTs = (ts: string) => ts.split("T")[0] || new Date().toISOString().split("T")[0]!
57
+
58
+ const escapeValue = (value: string) => value
59
+ .replace(/\\/g, "\\\\")
60
+ .replace(/\n/g, "\\n")
61
+ .replace(/\r/g, "\\r")
62
+ .replace(/"/g, '\\"')
63
+
64
+ const unescapeValue = (value: string) => {
65
+ let result = ""
66
+ for (let i = 0; i < value.length; i++) {
67
+ const char = value[i]
68
+ if (char !== "\\") {
69
+ result += char
70
+ continue
71
+ }
72
+
73
+ const next = value[++i]
74
+ if (next === "n") result += "\n"
75
+ else if (next === "r") result += "\r"
76
+ else if (next === '"') result += '"'
77
+ else if (next === "\\") result += "\\"
78
+ else if (next !== undefined) result += `\\${next}`
79
+ }
80
+ return result
81
+ }
82
+
83
+ const needsQuotes = (value: string) => value === "" || /\s|"|\\/.test(value)
84
+
85
+ const field = (key: string, value: string, alwaysQuote = false) => {
86
+ if (!alwaysQuote && !needsQuotes(value)) return `${key}=${value}`
87
+ return `${key}="${escapeValue(value)}"`
88
+ }
89
+
90
+ const parseFields = (line: string): Record<string, string> => {
91
+ const fields: Record<string, string> = {}
92
+ let index = 0
93
+
94
+ while (index < line.length) {
95
+ while (line[index] === " ") index++
96
+
97
+ const keyStart = index
98
+ while (index < line.length && line[index] !== "=" && line[index] !== " ") index++
99
+ const key = line.slice(keyStart, index)
100
+ if (!key || line[index] !== "=") break
101
+ index++
102
+
103
+ if (line[index] === '"') {
104
+ index++
105
+ let value = ""
106
+ while (index < line.length) {
107
+ const char = line[index]
108
+ if (char === '"') {
109
+ index++
110
+ break
111
+ }
112
+ if (char === "\\" && index + 1 < line.length) {
113
+ value += char + line[index + 1]
114
+ index += 2
115
+ continue
116
+ }
117
+ value += char
118
+ index++
119
+ }
120
+ fields[key] = unescapeValue(value)
121
+ continue
122
+ }
123
+
124
+ const valueStart = index
125
+ while (index < line.length && line[index] !== " ") index++
126
+ fields[key] = line.slice(valueStart, index)
127
+ }
128
+
129
+ return fields
130
+ }
131
+
26
132
  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]+)/)
133
+ const fields = parseFields(line)
134
+ const { ts, type, scope } = fields
33
135
 
34
- if (!tsMatch?.[1] || !typeMatch?.[1] || !scopeMatch?.[1]) return null
136
+ if (!ts || !type || !scope || !isMemoryType(type)) return null
35
137
 
36
138
  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(","),
139
+ ts,
140
+ type,
141
+ scope,
142
+ content: fields.content || "",
143
+ issue: fields.issue,
144
+ tags: fields.tags ? fields.tags.split(",").filter(Boolean) : undefined,
43
145
  }
44
146
  }
45
147
 
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}`
148
+ const encodeMemory = (memory: Memory): string => {
149
+ const parts = [
150
+ field("ts", memory.ts),
151
+ field("type", memory.type),
152
+ field("scope", memory.scope),
153
+ field("content", memory.content, true),
154
+ ]
155
+
156
+ if (memory.issue) parts.push(field("issue", memory.issue))
157
+ if (memory.tags?.length) parts.push(field("tags", memory.tags.join(",")))
158
+
159
+ return parts.join(" ")
160
+ }
161
+
162
+ const encodeDeletion = (memory: Memory, reason: string): string => {
163
+ const parts = [
164
+ field("ts", new Date().toISOString()),
165
+ field("action", "deleted"),
166
+ field("original_ts", memory.ts),
167
+ field("type", memory.type),
168
+ field("scope", memory.scope),
169
+ field("content", memory.content, true),
170
+ field("reason", reason, true),
171
+ ]
172
+
173
+ if (memory.issue) parts.push(field("issue", memory.issue))
174
+ if (memory.tags?.length) parts.push(field("tags", memory.tags.join(",")))
175
+
176
+ return parts.join(" ")
177
+ }
178
+
179
+ const formatMemory = (memory: Memory): string => {
180
+ const date = dateFromTs(memory.ts)
181
+ const tags = memory.tags?.length ? ` [${memory.tags.join(", ")}]` : ""
182
+ const issue = memory.issue ? ` (${memory.issue})` : ""
183
+ return `[${date}] ${memory.type}/${memory.scope}: ${memory.content}${issue}${tags}`
51
184
  }
52
185
 
53
186
  const scoreMatch = (memory: Memory, words: string[]): number => {
@@ -57,304 +190,587 @@ const scoreMatch = (memory: Memory, words: string[]): number => {
57
190
  if (searchable.includes(word)) score++
58
191
  if (memory.scope.toLowerCase() === word) score += 2
59
192
  if (memory.type.toLowerCase() === word) score += 2
193
+ if (memory.tags?.some((tag) => tag.toLowerCase() === word)) score += 2
60
194
  }
61
195
  return score
62
196
  }
63
197
 
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()
198
+ const typePriority: Record<MemoryType, number> = {
199
+ preference: 6,
200
+ decision: 5,
201
+ blocker: 4,
202
+ pattern: 3,
203
+ context: 2,
204
+ learning: 1,
205
+ }
77
206
 
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`
207
+ const truncate = (value: string, maxLength: number) => {
208
+ const normalized = value.replace(/\s+/g, " ").trim()
209
+ if (normalized.length <= maxLength) return normalized
210
+ return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`
211
+ }
83
212
 
84
- const file = getMemoryFile()
85
- const existing = (await file.exists()) ? await file.text() : ""
86
- await Bun.write(file, existing + line)
213
+ const buildContextPack = (memories: Memory[], options: ContextOptions) => {
214
+ const query = options.query?.trim()
215
+ const words = query?.toLowerCase().split(/\s+/).filter(Boolean) || []
216
+ const minScore = options.minScore ?? (query ? 1 : 0)
217
+ const maxChars = options.maxChars && options.maxChars > 0 ? Math.floor(options.maxChars) : 1200
218
+ const limit = options.limit && options.limit > 0 ? Math.floor(options.limit) : 5
219
+ let results = memories
220
+
221
+ if (options.scope) results = results.filter((memory) => matchesScope(memory, options.scope!, "contains"))
222
+ if (options.types?.length) results = results.filter((memory) => options.types!.includes(memory.type))
223
+ if (options.tags?.length) {
224
+ const tags = options.tags.map((tag) => tag.toLowerCase())
225
+ results = results.filter((memory) => {
226
+ const memoryTags = memory.tags?.map((tag) => tag.toLowerCase()) || []
227
+ return tags.every((tag) => memoryTags.includes(tag))
228
+ })
229
+ }
87
230
 
88
- return `Remembered: ${args.type} in ${args.scope}`
89
- },
90
- })
231
+ const ranked = results
232
+ .map((memory) => ({
233
+ memory,
234
+ score: words.length ? scoreMatch(memory, words) : 0,
235
+ }))
236
+ .filter((item) => item.score >= minScore)
237
+ .sort((a, b) => {
238
+ const priority = typePriority[b.memory.type] - typePriority[a.memory.type]
239
+ return b.score - a.score || priority || b.memory.ts.localeCompare(a.memory.ts)
240
+ })
241
+ .slice(0, limit)
242
+
243
+ if (!ranked.length) return ""
244
+
245
+ const lines = ["Relevant Memory:"]
246
+ let used = lines[0]!.length + 1
247
+
248
+ for (const { memory } of ranked) {
249
+ const prefix = `- ${memory.type}/${memory.scope}: `
250
+ const remaining = maxChars - used - prefix.length
251
+ if (remaining <= 20) break
252
+
253
+ const line = `${prefix}${truncate(memory.content, Math.min(remaining, 260))}`
254
+ lines.push(line)
255
+ used += line.length + 1
256
+ }
91
257
 
92
- const getAllMemories = async (): Promise<Memory[]> => {
93
- const glob = new Bun.Glob("*.logfmt")
94
- const files = await Array.fromAsync(glob.scan(MEMORY_DIR))
258
+ return lines.length > 1 ? lines.join("\n") : ""
259
+ }
95
260
 
96
- if (!files.length) return []
261
+ const textFromParts = (parts: unknown[]) => parts
262
+ .map((part) => {
263
+ if (typeof part !== "object" || !part) return ""
264
+ if (!("type" in part) || part.type !== "text") return ""
265
+ if (!("text" in part) || typeof part.text !== "string") return ""
266
+ return part.text
267
+ })
268
+ .filter(Boolean)
269
+ .join("\n")
270
+ .trim()
271
+
272
+ const inferExplicitMemory = (text: string, defaultScope: string): Omit<Memory, "ts"> | null => {
273
+ if (/\b(don't|do not|dont)\s+remember\b/i.test(text)) return null
274
+
275
+ const match = text.match(/(?:^|\b)(?:please\s+)?remember(?:\s+that|:)?\s+([\s\S]+)$/i)
276
+ const content = match?.[1]?.trim()
277
+ if (!content) return null
278
+
279
+ const lower = content.toLowerCase()
280
+ const type: MemoryType = lower.includes("prefer")
281
+ ? "preference"
282
+ : lower.includes("decided") || lower.includes("decision")
283
+ ? "decision"
284
+ : lower.includes("blocked") || lower.includes("blocker")
285
+ ? "blocker"
286
+ : lower.includes("pattern") || lower.includes("always")
287
+ ? "pattern"
288
+ : "context"
97
289
 
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))
290
+ return {
291
+ type,
292
+ scope: defaultScope,
293
+ content,
294
+ tags: ["auto"],
104
295
  }
296
+ }
105
297
 
106
- return lines.map(parseLine).filter((m): m is Memory => m !== null)
298
+ const withTimeout = async <T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> => {
299
+ let timeout: Timer | undefined
300
+ try {
301
+ return await Promise.race([
302
+ promise,
303
+ new Promise<undefined>((resolve) => {
304
+ timeout = setTimeout(() => resolve(undefined), timeoutMs)
305
+ }),
306
+ ])
307
+ } finally {
308
+ if (timeout) clearTimeout(timeout)
309
+ }
107
310
  }
108
311
 
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)
312
+ const createStore = (dir: string): MemoryStore => ({
313
+ dir,
314
+ async ensureDir() {
315
+ await mkdir(dir, { recursive: true })
316
+ },
317
+ async appendMemory(memory) {
318
+ await this.ensureDir()
319
+ await appendFile(join(dir, `${dateFromTs(memory.ts)}.logfmt`), `${encodeMemory(memory)}\n`, "utf8")
320
+ },
321
+ async appendDeletion(memory, reason) {
322
+ await this.ensureDir()
323
+ await appendFile(join(dir, "deletions.logfmt"), `${encodeDeletion(memory, reason)}\n`, "utf8")
324
+ },
325
+ async readEntries() {
326
+ await this.ensureDir()
327
+ const glob = new Bun.Glob("*.logfmt")
328
+ const files = (await Array.fromAsync(glob.scan(dir)))
329
+ .filter((filename) => filename !== "deletions.logfmt")
330
+ .sort()
331
+ const entries: MemoryEntry[] = []
332
+
333
+ for (const filename of files) {
334
+ const filepath = join(dir, filename)
335
+ const file = Bun.file(filepath)
336
+ const text = await file.text()
337
+ const lines = text.split("\n")
338
+
339
+ lines.forEach((line, lineIndex) => {
340
+ const memory = parseLine(line)
341
+ if (memory) entries.push({ memory, filepath, lineIndex })
342
+ })
343
+ }
344
+
345
+ return entries.sort((a, b) => a.memory.ts.localeCompare(b.memory.ts))
346
+ },
347
+ async readDeletionLines() {
348
+ const file = Bun.file(join(dir, "deletions.logfmt"))
349
+ if (!(await file.exists())) return []
350
+ return (await file.text()).trim().split("\n").filter(Boolean)
351
+ },
352
+ async rewriteFile(filepath, lines) {
353
+ await this.ensureDir()
354
+ const tmp = `${filepath}.${crypto.randomUUID()}.tmp`
355
+ await Bun.write(tmp, lines.length ? `${lines.join("\n")}\n` : "")
356
+ await rename(tmp, filepath)
357
+ },
358
+ })
359
+
360
+ const matchesScope = (memory: Memory, scope: string, mode: "contains" | "exact" | "prefix") => {
361
+ if (mode === "exact") return memory.scope === scope
362
+ if (mode === "prefix") return memory.scope.startsWith(scope)
363
+ return memory.scope === scope || memory.scope.includes(scope)
122
364
  }
123
365
 
124
- const recall = tool({
125
- description: "Retrieve memories by scope, type, or search query",
366
+ const startOfDateFilter = (value: string) => value.includes("T") ? value : `${value}T00:00:00.000Z`
367
+
368
+ const endOfDateFilter = (value: string) => value.includes("T") ? value : `${value}T23:59:59.999Z`
369
+
370
+ const filterMemories = (
371
+ memories: Memory[],
126
372
  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)"),
373
+ scope?: string
374
+ type?: MemoryType
375
+ query?: string
376
+ tags?: string[]
377
+ since?: string
378
+ until?: string
379
+ match?: "contains" | "exact" | "prefix"
134
380
  },
135
- async execute(args) {
136
- let results = await getAllMemories()
381
+ ) => {
382
+ let results = memories
383
+ const match = args.match || "contains"
384
+
385
+ if (args.scope) results = results.filter((memory) => matchesScope(memory, args.scope!, match))
386
+ if (args.type) results = results.filter((memory) => memory.type === args.type)
387
+ if (args.tags?.length) {
388
+ const tags = args.tags.map((tag) => tag.toLowerCase())
389
+ results = results.filter((memory) => {
390
+ const memoryTags = memory.tags?.map((tag) => tag.toLowerCase()) || []
391
+ return tags.every((tag) => memoryTags.includes(tag))
392
+ })
393
+ }
394
+ if (args.since) {
395
+ const since = startOfDateFilter(args.since)
396
+ results = results.filter((memory) => memory.ts >= since)
397
+ }
398
+ if (args.until) {
399
+ const until = endOfDateFilter(args.until)
400
+ results = results.filter((memory) => memory.ts <= until)
401
+ }
137
402
 
138
- if (!results.length) return "No memories found"
403
+ if (!args.query) return results
139
404
 
140
- const totalCount = results.length
405
+ const words = args.query.toLowerCase().split(/\s+/).filter(Boolean)
406
+ return results
407
+ .map((memory) => ({ memory, score: scoreMatch(memory, words) }))
408
+ .filter((item) => item.score > 0)
409
+ .sort((a, b) => b.score - a.score || b.memory.ts.localeCompare(a.memory.ts))
410
+ .map((item) => item.memory)
411
+ }
141
412
 
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)
413
+ const chooseUpdateTarget = (matches: MemoryEntry[], query?: string) => {
414
+ if (matches.length <= 1) return { target: matches[0], message: undefined }
415
+ if (!query) {
416
+ return {
417
+ target: undefined,
418
+ message: `Found ${matches.length} memories for ${matches[0]!.memory.type}/${matches[0]!.memory.scope}. Provide a query to select which one to update, or use recall to see all matches.`,
147
419
  }
420
+ }
148
421
 
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
- }
422
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean)
423
+ const scored = matches
424
+ .map((entry) => ({ ...entry, score: scoreMatch(entry.memory, words) }))
425
+ .filter((entry) => entry.score > 0)
426
+ .sort((a, b) => b.score - a.score || b.memory.ts.localeCompare(a.memory.ts))
157
427
 
158
- const filteredCount = results.length
159
- const limit = args.limit || 20
160
- const limited = results.slice(-limit)
428
+ if (!scored.length) {
429
+ return {
430
+ target: undefined,
431
+ message: `Found ${matches.length} memories for ${matches[0]!.memory.type}/${matches[0]!.memory.scope}, but none matched query "${query}". Use recall to see all matches.`,
432
+ }
433
+ }
161
434
 
162
- if (!limited.length) return "No matching memories"
435
+ return { target: scored[0], message: undefined }
436
+ }
163
437
 
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`
438
+ const createTools = (store: MemoryStore) => {
439
+ const remember = tool({
440
+ description: "Store a memory (decision, learning, preference, blocker, context, pattern)",
441
+ args: {
442
+ type: tool.schema.enum(MEMORY_TYPES).describe("Type of memory"),
443
+ scope: tool.schema.string().describe("Scope/area (e.g., auth, api, mobile)"),
444
+ content: tool.schema.string().describe("The memory content"),
445
+ issue: tool.schema.string().optional().describe("Related GitHub issue (e.g., #51)"),
446
+ tags: tool.schema.array(tool.schema.string()).optional().describe("Additional tags"),
447
+ },
448
+ async execute(args) {
449
+ await store.appendMemory({
450
+ ts: new Date().toISOString(),
451
+ type: args.type,
452
+ scope: args.scope.trim(),
453
+ content: args.content,
454
+ issue: args.issue?.trim() || undefined,
455
+ tags: args.tags?.map((tag) => tag.trim()).filter(Boolean),
456
+ })
169
457
 
170
- return header + limited.map(formatMemory).join("\n")
171
- },
172
- })
458
+ return `Remembered: ${args.type} in ${args.scope}`
459
+ },
460
+ })
461
+
462
+ const recall = tool({
463
+ description: "Retrieve memories by scope, type, tag, date, or search query",
464
+ args: {
465
+ scope: tool.schema.string().optional().describe("Filter by scope"),
466
+ type: tool.schema.enum(MEMORY_TYPES).optional().describe("Filter by type"),
467
+ query: tool.schema.string().optional().describe("Search term (space-separated words, matches any)"),
468
+ limit: tool.schema.number().optional().describe("Max results (default 20)"),
469
+ tags: tool.schema.array(tool.schema.string()).optional().describe("Only include memories with all of these tags"),
470
+ since: tool.schema.string().optional().describe("Only include memories at or after this ISO timestamp/date"),
471
+ until: tool.schema.string().optional().describe("Only include memories at or before this ISO timestamp/date"),
472
+ match: tool.schema.enum(["contains", "exact", "prefix"]).optional().describe("Scope match mode (default contains, matching earlier behavior)"),
473
+ },
474
+ async execute(args) {
475
+ const memories = (await store.readEntries()).map((entry) => entry.memory)
173
476
 
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))
477
+ if (!memories.length) return "No memories found"
189
478
 
190
- if (!files.length) return "No memory files found"
479
+ const totalCount = memories.length
480
+ const results = filterMemories(memories, args)
481
+ const filteredCount = results.length
482
+ const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : 20
483
+ const limited = args.query ? results.slice(0, limit) : results.slice(-limit)
191
484
 
192
- // Find matching memories
193
- const matches: { memory: Memory; filepath: string; lineIndex: number }[] = []
485
+ if (!limited.length) return "No matching memories"
194
486
 
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")
487
+ const header = filteredCount > limit
488
+ ? `Found ${filteredCount} memories (showing ${args.query ? "best" : "last"} ${limit} of ${totalCount} total)\n\n`
489
+ : filteredCount !== totalCount
490
+ ? `Found ${filteredCount} memories (${totalCount} total)\n\n`
491
+ : `Found ${filteredCount} memories\n\n`
201
492
 
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
- }
493
+ return header + limited.map(formatMemory).join("\n")
494
+ },
495
+ })
496
+
497
+ const update = tool({
498
+ description: "Update an existing memory by scope and type (finds matching memory and updates its content)",
499
+ args: {
500
+ scope: tool.schema.string().describe("Scope of memory to update"),
501
+ type: tool.schema.enum(MEMORY_TYPES).describe("Type of memory"),
502
+ content: tool.schema.string().describe("The new content for the memory"),
503
+ query: tool.schema.string().optional().describe("Search term to find specific memory if multiple exist"),
504
+ issue: tool.schema.string().optional().describe("Update related GitHub issue (e.g., #51)"),
505
+ tags: tool.schema.array(tool.schema.string()).optional().describe("Update tags"),
506
+ },
507
+ async execute(args) {
508
+ const matches = (await store.readEntries()).filter((entry) => entry.memory.scope === args.scope && entry.memory.type === args.type)
509
+
510
+ if (!matches.length) return `No memories found for ${args.type} in ${args.scope}`
511
+
512
+ const { target, message } = chooseUpdateTarget(matches, args.query)
513
+ if (message) return message
514
+ if (!target) return `No memories found for ${args.type} in ${args.scope}`
515
+
516
+ await store.appendDeletion(target.memory, `Updated to: ${args.content}`)
517
+
518
+ const file = Bun.file(target.filepath)
519
+ const lines = (await file.text()).split("\n")
520
+ lines[target.lineIndex] = encodeMemory({
521
+ ts: new Date().toISOString(),
522
+ type: args.type,
523
+ scope: args.scope,
524
+ content: args.content,
525
+ issue: args.issue !== undefined ? args.issue : target.memory.issue,
526
+ tags: args.tags !== undefined ? args.tags : target.memory.tags,
208
527
  })
209
- }
528
+ await store.rewriteFile(target.filepath, lines.filter((line) => line.length > 0))
210
529
 
211
- if (matches.length === 0) {
212
- return `No memories found for ${args.type} in ${args.scope}`
213
- }
530
+ return `Updated ${args.type} in ${args.scope}: "${args.content}"`
531
+ },
532
+ })
214
533
 
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)
534
+ const listMemories = tool({
535
+ description: "List all unique scopes and types in memory for discovery",
536
+ args: {},
537
+ async execute() {
538
+ const memories = (await store.readEntries()).map((entry) => entry.memory)
224
539
 
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
- }
540
+ if (!memories.length) return "No memories found"
233
541
 
234
- if (!target) {
235
- return `No memories found for ${args.type} in ${args.scope}`
236
- }
542
+ const scopes = new Map<string, number>()
543
+ const types = new Map<string, number>()
544
+ const scopeTypes = new Map<string, Set<string>>()
237
545
 
238
- // Log the old version before updating
239
- await logDeletion(target.memory, `Updated to: ${args.content}`)
546
+ for (const memory of memories) {
547
+ scopes.set(memory.scope, (scopes.get(memory.scope) || 0) + 1)
548
+ types.set(memory.type, (types.get(memory.type) || 0) + 1)
549
+ if (!scopeTypes.has(memory.scope)) scopeTypes.set(memory.scope, new Set())
550
+ scopeTypes.get(memory.scope)!.add(memory.type)
551
+ }
240
552
 
241
- // Update the memory
242
- const file = Bun.file(target.filepath)
243
- const text = await file.text()
244
- const lines = text.split("\n")
553
+ const blockers = memories.filter((memory) => memory.type === "blocker")
554
+ const lines = [`Total memories: ${memories.length}`, "", "Scopes:"]
555
+ for (const [scope, count] of [...scopes.entries()].sort((a, b) => b[1] - a[1])) {
556
+ const typeList = [...scopeTypes.get(scope)!].join(", ")
557
+ lines.push(` ${scope}: ${count} (${typeList})`)
558
+ }
559
+ lines.push("", "Types:")
560
+ for (const [type, count] of [...types.entries()].sort((a, b) => b[1] - a[1])) {
561
+ lines.push(` ${type}: ${count}`)
562
+ }
563
+ if (blockers.length) lines.push("", `Open blockers: ${blockers.length}`)
245
564
 
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}`
565
+ return lines.join("\n")
566
+ },
567
+ })
568
+
569
+ const forget = tool({
570
+ description: "Delete memories by scope and type (optionally narrowed by query; logs deletion for audit)",
571
+ args: {
572
+ scope: tool.schema.string().describe("Scope of memory to delete"),
573
+ type: tool.schema.enum(MEMORY_TYPES).describe("Type of memory"),
574
+ reason: tool.schema.string().describe("Why this is being deleted (for audit purposes)"),
575
+ query: tool.schema.string().optional().describe("Optional search term to delete only the best matching memory"),
576
+ },
577
+ async execute(args) {
578
+ const entries = await store.readEntries()
579
+ let matches = entries.filter((entry) => entry.memory.scope === args.scope && entry.memory.type === args.type)
253
580
 
254
- lines[target.lineIndex] = newLine
255
- await Bun.write(target.filepath, lines.join("\n"))
581
+ if (args.query && matches.length) {
582
+ const words = args.query.toLowerCase().split(/\s+/).filter(Boolean)
583
+ const scored = matches
584
+ .map((entry) => ({ ...entry, score: scoreMatch(entry.memory, words) }))
585
+ .filter((entry) => entry.score > 0)
586
+ .sort((a, b) => b.score - a.score || b.memory.ts.localeCompare(a.memory.ts))
587
+ matches = scored[0] ? [scored[0]] : []
588
+ }
256
589
 
257
- return `Updated ${args.type} in ${args.scope}: "${args.content}"`
258
- },
259
- })
590
+ if (!matches.length) return `No memories found for ${args.type} in ${args.scope}`
260
591
 
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()
592
+ const byFile = new Map<string, Set<number>>()
593
+ for (const match of matches) {
594
+ if (!byFile.has(match.filepath)) byFile.set(match.filepath, new Set())
595
+ byFile.get(match.filepath)!.add(match.lineIndex)
596
+ }
266
597
 
267
- if (!memories.length) return "No memories found"
598
+ for (const [filepath, lineIndexes] of byFile) {
599
+ const lines = (await Bun.file(filepath).text()).split("\n")
600
+ const filtered = lines.filter((line, index) => line.length > 0 && !lineIndexes.has(index))
601
+ await store.rewriteFile(filepath, filtered)
602
+ }
603
+ for (const match of matches) await store.appendDeletion(match.memory, args.reason)
268
604
 
269
- const scopes = new Map<string, number>()
270
- const types = new Map<string, number>()
271
- const scopeTypes = new Map<string, Set<string>>()
605
+ return `Deleted ${matches.length} ${args.type} memory(s) from ${args.scope}. Reason: ${args.reason}\nDeletions logged to ${join(store.dir, "deletions.logfmt")}`
606
+ },
607
+ })
272
608
 
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
- }
609
+ const exportMemories = tool({
610
+ description: "Export memories as jsonl, json, or logfmt",
611
+ args: {
612
+ format: tool.schema.enum(["jsonl", "json", "logfmt"]).optional().describe("Export format (default jsonl)"),
613
+ includeDeletions: tool.schema.boolean().optional().describe("Include deletion audit lines for logfmt exports"),
614
+ },
615
+ async execute(args) {
616
+ const format = args.format || "jsonl"
617
+ const memories = (await store.readEntries()).map((entry) => entry.memory)
618
+
619
+ if (format === "json") return JSON.stringify(memories, null, 2)
620
+ if (format === "logfmt") {
621
+ const lines = memories.map(encodeMemory)
622
+ if (args.includeDeletions) lines.push(...await store.readDeletionLines())
623
+ return lines.join("\n")
624
+ }
625
+ return memories.map((memory) => JSON.stringify(memory)).join("\n")
626
+ },
627
+ })
279
628
 
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
- }
629
+ const importMemories = tool({
630
+ description: "Import memories from jsonl, json, or compatible logfmt",
631
+ args: {
632
+ data: tool.schema.string().describe("Memory data to import"),
633
+ format: tool.schema.enum(["jsonl", "json", "logfmt"]).optional().describe("Import format (default jsonl)"),
634
+ },
635
+ async execute(args) {
636
+ const format = args.format || "jsonl"
637
+ const imported: Memory[] = []
638
+
639
+ if (format === "json") {
640
+ const parsed = JSON.parse(args.data) as Memory[]
641
+ imported.push(...parsed)
642
+ } else if (format === "logfmt") {
643
+ imported.push(...args.data.split("\n").map(parseLine).filter((memory): memory is Memory => memory !== null))
644
+ } else {
645
+ imported.push(...args.data.split("\n").filter(Boolean).map((line) => JSON.parse(line) as Memory))
646
+ }
293
647
 
294
- return lines.join("\n")
295
- },
296
- })
648
+ let count = 0
649
+ for (const memory of imported) {
650
+ if (!isMemoryType(memory.type)) continue
651
+ await store.appendMemory({
652
+ ts: memory.ts || new Date().toISOString(),
653
+ type: memory.type,
654
+ scope: memory.scope,
655
+ content: memory.content,
656
+ issue: memory.issue,
657
+ tags: memory.tags,
658
+ })
659
+ count++
660
+ }
297
661
 
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))
662
+ return `Imported ${count} memory(s)`
663
+ },
664
+ })
310
665
 
311
- if (!files.length) return "No memory files found"
666
+ const compact = tool({
667
+ description: "Rewrite memory files in chronological order and remove exact duplicate records",
668
+ args: {
669
+ dryRun: tool.schema.boolean().optional().describe("Report what would change without rewriting files"),
670
+ },
671
+ async execute(args) {
672
+ const entries = await store.readEntries()
673
+ const unique = new Map<string, Memory>()
674
+ for (const entry of entries) {
675
+ const key = JSON.stringify(entry.memory)
676
+ if (!unique.has(key)) unique.set(key, entry.memory)
677
+ }
312
678
 
313
- let deleted = 0
314
- const deletedMemories: Memory[] = []
679
+ const duplicateCount = entries.length - unique.size
680
+ if (args.dryRun) return `Would compact ${entries.length} memories to ${unique.size} unique memories (${duplicateCount} duplicate(s) removed)`
315
681
 
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"))
682
+ const byDate = new Map<string, string[]>()
683
+ for (const memory of [...unique.values()].sort((a, b) => a.ts.localeCompare(b.ts))) {
684
+ const date = dateFromTs(memory.ts)
685
+ if (!byDate.has(date)) byDate.set(date, [])
686
+ byDate.get(date)!.push(encodeMemory(memory))
334
687
  }
335
- }
336
688
 
337
- // Log all deletions to audit file
338
- for (const memory of deletedMemories) {
339
- await logDeletion(memory, args.reason)
340
- }
689
+ const files = new Set(entries.map((entry) => entry.filepath))
690
+ for (const filepath of files) await store.rewriteFile(filepath, [])
691
+ for (const [date, lines] of byDate) await store.rewriteFile(join(store.dir, `${date}.logfmt`), lines)
341
692
 
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
- })
693
+ return `Compacted ${entries.length} memories to ${unique.size} unique memories (${duplicateCount} duplicate(s) removed)`
694
+ },
695
+ })
696
+
697
+ const context = tool({
698
+ description: "Build a compact relevant-memory context pack for the current task",
699
+ args: {
700
+ query: tool.schema.string().optional().describe("Task text to match against memories"),
701
+ scope: tool.schema.string().optional().describe("Optional scope filter"),
702
+ tags: tool.schema.array(tool.schema.string()).optional().describe("Only include memories with all of these tags"),
703
+ types: tool.schema.array(tool.schema.enum(MEMORY_TYPES)).optional().describe("Only include these memory types"),
704
+ limit: tool.schema.number().optional().describe("Maximum memories to include (default 5)"),
705
+ maxChars: tool.schema.number().optional().describe("Maximum characters in the context pack (default 1200)"),
706
+ minScore: tool.schema.number().optional().describe("Minimum query relevance score (default 1 when query is provided)"),
707
+ },
708
+ async execute(args) {
709
+ const memories = (await store.readEntries()).map((entry) => entry.memory)
710
+ const pack = buildContextPack(memories, args)
711
+ return pack || "No relevant memories"
712
+ },
713
+ })
346
714
 
347
- export const MemoryPlugin: Plugin = async (_ctx) => {
348
- MEMORY_DIR = `${_ctx.directory}/.opencode/memory`
349
715
  return {
350
- tool: {
351
- memory_remember: remember,
352
- memory_recall: recall,
353
- memory_update: update,
354
- memory_forget: forget,
355
- memory_list: listMemories,
356
- },
716
+ memory_remember: remember,
717
+ memory_recall: recall,
718
+ memory_update: update,
719
+ memory_forget: forget,
720
+ memory_list: listMemories,
721
+ memory_export: exportMemories,
722
+ memory_import: importMemories,
723
+ memory_compact: compact,
724
+ memory_context: context,
357
725
  }
358
726
  }
359
727
 
728
+ export const MemoryPlugin = (async (ctx, options?: PluginOptions) => {
729
+ const store = createStore(join(ctx.directory, ".opencode", "memory"))
730
+ const autoLoad = options?.autoLoad ?? false
731
+ const autoSave = options?.autoSave ?? false
732
+ const autoHookTimeoutMs = options?.autoHookTimeoutMs && options.autoHookTimeoutMs > 0 ? options.autoHookTimeoutMs : 100
733
+ let latestPrompt: string | undefined
734
+
735
+ return {
736
+ tool: createTools(store),
737
+ "chat.message": async (input, output) => {
738
+ const text = textFromParts(output.parts)
739
+ if (!text) return
740
+
741
+ latestPrompt = text
742
+
743
+ if (!autoSave) return
744
+
745
+ await withTimeout((async () => {
746
+ const memory = inferExplicitMemory(text, options?.autoSaveScope || "user")
747
+ if (!memory) return
748
+
749
+ await store.appendMemory({
750
+ ...memory,
751
+ ts: new Date().toISOString(),
752
+ })
753
+ })(), autoHookTimeoutMs)
754
+ },
755
+ "experimental.chat.system.transform": async (_input, output) => {
756
+ if (!autoLoad) return
757
+
758
+ if (!latestPrompt) return
759
+
760
+ const pack = await withTimeout((async () => {
761
+ const memories = (await store.readEntries()).map((entry) => entry.memory)
762
+ return buildContextPack(memories, {
763
+ query: latestPrompt,
764
+ limit: options?.contextLimit,
765
+ maxChars: options?.contextMaxChars,
766
+ minScore: options?.contextMinScore,
767
+ })
768
+ })(), autoHookTimeoutMs)
769
+ if (!pack) return
770
+
771
+ output.system.push(`${pack}\n\nUse these memories only when they are relevant. Do not mention this block unless asked.`)
772
+ },
773
+ }
774
+ }) satisfies Plugin
775
+
360
776
  export default MemoryPlugin