@knikolov/opencode-plugin-simple-memory 1.0.3 → 1.1.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 +144 -2
- package/index.ts +1 -0
- package/package.json +16 -5
- package/src/index.ts +687 -270
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
|
|
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/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knikolov/opencode-plugin-simple-memory",
|
|
3
3
|
"module": "index.ts",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
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",
|
|
13
|
-
"dependencies": {
|
|
14
|
-
"@opencode-ai/plugin": "^1.0.157"
|
|
15
|
-
},
|
|
16
26
|
"devDependencies": {
|
|
27
|
+
"@opencode-ai/plugin": "^1.0.153",
|
|
17
28
|
"@types/bun": "latest"
|
|
18
29
|
},
|
|
19
30
|
"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
|
-
const
|
|
5
|
+
const MEMORY_TYPES = ["decision", "learning", "preference", "blocker", "context", "pattern"] as const
|
|
4
6
|
|
|
5
|
-
|
|
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:
|
|
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
|
|
28
|
-
const
|
|
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 (!
|
|
136
|
+
if (!ts || !type || !scope || !isMemoryType(type)) return null
|
|
35
137
|
|
|
36
138
|
return {
|
|
37
|
-
ts
|
|
38
|
-
type
|
|
39
|
-
scope
|
|
40
|
-
content:
|
|
41
|
-
issue:
|
|
42
|
-
tags:
|
|
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
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,303 +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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
const files = await Array.fromAsync(glob.scan(MEMORY_DIR))
|
|
258
|
+
return lines.length > 1 ? lines.join("\n") : ""
|
|
259
|
+
}
|
|
95
260
|
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
125
|
-
|
|
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
|
|
128
|
-
type
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
403
|
+
if (!args.query) return results
|
|
139
404
|
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
435
|
+
return { target: scored[0], message: undefined }
|
|
436
|
+
}
|
|
163
437
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
const matches: { memory: Memory; filepath: string; lineIndex: number }[] = []
|
|
485
|
+
if (!limited.length) return "No matching memories"
|
|
194
486
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
530
|
+
return `Updated ${args.type} in ${args.scope}: "${args.content}"`
|
|
531
|
+
},
|
|
532
|
+
})
|
|
214
533
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
},
|
|
259
|
-
})
|
|
590
|
+
if (!matches.length) return `No memories found for ${args.type} in ${args.scope}`
|
|
260
591
|
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
await
|
|
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
|
-
|
|
343
|
-
|
|
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
715
|
return {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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,
|
|
356
725
|
}
|
|
357
726
|
}
|
|
358
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
|
+
|
|
359
776
|
export default MemoryPlugin
|