opencode-claude-memory 0.1.0 → 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/LICENSE +21 -0
- package/README.md +128 -83
- package/bin/opencode +35 -0
- package/package.json +5 -1
- package/src/index.ts +21 -3
- package/src/memory.ts +30 -6
- package/src/paths.ts +29 -0
- package/src/prompt.ts +6 -2
- package/src/recall.ts +126 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 kuitos
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,19 +1,71 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# 🧠 opencode-claude-memory
|
|
4
4
|
|
|
5
|
-
Claude Code
|
|
6
|
-
OpenCode writes memories → Claude Code reads them.
|
|
5
|
+
**A 1:1 replica of [Claude Code's memory system](https://github.com/anthropics/claude-code) for OpenCode**
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
*Ported from the original source — same paths, same format, same tools, same prompts. Zero drift.*
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
- **Claude Code compatible**: shares the same `~/.claude/projects/<project>/memory/` directory
|
|
12
|
-
- **Auto-extraction**: shell wrapper that automatically extracts memories after each session
|
|
13
|
-
- **System prompt injection**: existing memories are injected into every conversation
|
|
14
|
-
- **4 memory types**: `user`, `feedback`, `project`, `reference` (same taxonomy as Claude Code)
|
|
9
|
+
Claude Code writes memories → OpenCode reads them. OpenCode writes memories → Claude Code reads them.
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
[](https://www.npmjs.com/package/opencode-claude-memory)
|
|
12
|
+
[](https://www.npmjs.com/package/opencode-claude-memory)
|
|
13
|
+
[](https://github.com/kuitos/opencode-claude-memory/blob/main/LICENSE)
|
|
14
|
+
|
|
15
|
+
[Features](#-features) • [Quick Start](#-quick-start) • [How It Works](#-how-it-works) • [Configuration](#%EF%B8%8F-configuration) • [Tools Reference](#-tools-reference)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## ✨ Features
|
|
22
|
+
|
|
23
|
+
<table>
|
|
24
|
+
<tr>
|
|
25
|
+
<td width="50%">
|
|
26
|
+
|
|
27
|
+
### 🔄 Claude Code Compatible
|
|
28
|
+
Shares the exact same `~/.claude/projects/<project>/memory/` directory — bidirectional sync out of the box
|
|
29
|
+
|
|
30
|
+
</td>
|
|
31
|
+
<td width="50%">
|
|
32
|
+
|
|
33
|
+
### 🛠️ 5 Memory Tools
|
|
34
|
+
`memory_save`, `memory_delete`, `memory_list`, `memory_search`, `memory_read`
|
|
35
|
+
|
|
36
|
+
</td>
|
|
37
|
+
</tr>
|
|
38
|
+
<tr>
|
|
39
|
+
<td width="50%">
|
|
40
|
+
|
|
41
|
+
### ⚡ Auto-Extraction
|
|
42
|
+
Drop-in `opencode` wrapper that extracts memories in the background after each session
|
|
43
|
+
|
|
44
|
+
</td>
|
|
45
|
+
<td width="50%">
|
|
46
|
+
|
|
47
|
+
### 💉 System Prompt Injection
|
|
48
|
+
Existing memories are automatically injected into every conversation
|
|
49
|
+
|
|
50
|
+
</td>
|
|
51
|
+
</tr>
|
|
52
|
+
<tr>
|
|
53
|
+
<td width="50%">
|
|
54
|
+
|
|
55
|
+
### 📁 4 Memory Types
|
|
56
|
+
`user`, `feedback`, `project`, `reference` — same taxonomy as Claude Code
|
|
57
|
+
|
|
58
|
+
</td>
|
|
59
|
+
<td width="50%">
|
|
60
|
+
|
|
61
|
+
### 🌳 Git Worktree Aware
|
|
62
|
+
Worktrees of the same repo share the same memory directory
|
|
63
|
+
|
|
64
|
+
</td>
|
|
65
|
+
</tr>
|
|
66
|
+
</table>
|
|
67
|
+
|
|
68
|
+
## 🚀 Quick Start
|
|
17
69
|
|
|
18
70
|
### 1. Install
|
|
19
71
|
|
|
@@ -21,16 +73,11 @@ OpenCode writes memories → Claude Code reads them.
|
|
|
21
73
|
npm install -g opencode-claude-memory
|
|
22
74
|
```
|
|
23
75
|
|
|
24
|
-
This
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
- Places an `opencode` **wrapper** in your global bin that auto-extracts memories after each session
|
|
28
|
-
|
|
29
|
-
> The wrapper is a drop-in replacement — it finds the real `opencode` binary in `PATH`, runs it normally, then triggers memory extraction in the background when you exit.
|
|
76
|
+
This installs:
|
|
77
|
+
- The **plugin** — memory tools + system prompt injection
|
|
78
|
+
- An `opencode` **wrapper** — auto-extracts memories after each session
|
|
30
79
|
|
|
31
|
-
### 2. Configure
|
|
32
|
-
|
|
33
|
-
Add the plugin to your `opencode.json`:
|
|
80
|
+
### 2. Configure
|
|
34
81
|
|
|
35
82
|
```jsonc
|
|
36
83
|
// opencode.json
|
|
@@ -41,62 +88,85 @@ Add the plugin to your `opencode.json`:
|
|
|
41
88
|
|
|
42
89
|
### 3. Use
|
|
43
90
|
|
|
44
|
-
|
|
91
|
+
```bash
|
|
92
|
+
opencode # just use it as usual
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The AI agent can now use memory tools:
|
|
45
96
|
|
|
46
97
|
- **"Remember that I prefer terse responses"** → saves a `feedback` memory
|
|
47
98
|
- **"What do you remember about me?"** → reads from memory
|
|
48
99
|
- **"Forget the memory about my role"** → deletes a memory
|
|
49
100
|
|
|
50
|
-
When you exit
|
|
101
|
+
When you exit, memories are extracted in the background — zero blocking.
|
|
51
102
|
|
|
52
|
-
|
|
103
|
+
<details>
|
|
104
|
+
<summary>🗑️ Uninstall</summary>
|
|
53
105
|
|
|
54
106
|
```bash
|
|
55
107
|
npm uninstall -g opencode-claude-memory
|
|
56
108
|
```
|
|
57
109
|
|
|
58
|
-
This removes
|
|
110
|
+
This removes the wrapper and the plugin. Your saved memories in `~/.claude/projects/` are **not** deleted.
|
|
111
|
+
|
|
112
|
+
</details>
|
|
59
113
|
|
|
60
|
-
##
|
|
114
|
+
## 💡 How It Works
|
|
61
115
|
|
|
62
|
-
|
|
116
|
+
```mermaid
|
|
117
|
+
graph LR
|
|
118
|
+
A[You run opencode] --> B[Wrapper finds real binary]
|
|
119
|
+
B --> C[Runs opencode normally]
|
|
120
|
+
C --> D[You exit]
|
|
121
|
+
D --> E[Get latest session ID]
|
|
122
|
+
E --> F[Fork session + extract memories]
|
|
123
|
+
F --> G[Memories saved to ~/.claude/projects/]
|
|
124
|
+
```
|
|
63
125
|
|
|
64
|
-
|
|
65
|
-
2. Runs it normally with all your arguments
|
|
66
|
-
3. After you exit, finds the most recent session
|
|
67
|
-
4. Forks that session and sends a memory extraction prompt
|
|
68
|
-
5. The extraction runs **in the background** — you're never blocked
|
|
126
|
+
The wrapper is a drop-in replacement that:
|
|
69
127
|
|
|
70
|
-
|
|
128
|
+
1. Scans `PATH` to find the real `opencode` binary (skipping itself)
|
|
129
|
+
2. Runs it with all your arguments
|
|
130
|
+
3. After you exit, forks the session with a memory extraction prompt
|
|
131
|
+
4. Extraction runs **in the background** — you're never blocked
|
|
71
132
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
133
|
+
### What "1:1 Replica" Means
|
|
134
|
+
|
|
135
|
+
Every core component is ported directly from [Claude Code's source](https://github.com/anthropics/claude-code):
|
|
136
|
+
|
|
137
|
+
| Component | Source |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `sanitizePath()` + `djb2Hash()` | `utils/sessionStoragePortable.ts` |
|
|
140
|
+
| `findGitRoot()` + worktree resolution | `utils/git.ts` |
|
|
141
|
+
| Memory types & frontmatter format | `commands/memory.ts` |
|
|
142
|
+
| System prompt (types, when to save/skip) | `commands/memory.ts` |
|
|
143
|
+
| Extraction prompt (post-session) | Claude Code's memory extraction agent |
|
|
144
|
+
|
|
145
|
+
This ensures:
|
|
146
|
+
- `~/.claude/projects/<sanitized>/memory/` paths are **byte-identical** to Claude Code's output
|
|
147
|
+
- Git worktrees resolve to the same canonical root
|
|
148
|
+
- Memory files are interchangeable — no migration needed
|
|
149
|
+
|
|
150
|
+
## ⚙️ Configuration
|
|
81
151
|
|
|
82
|
-
### Environment
|
|
152
|
+
### Environment Variables
|
|
83
153
|
|
|
84
154
|
| Variable | Default | Description |
|
|
85
155
|
|---|---|---|
|
|
86
156
|
| `OPENCODE_MEMORY_EXTRACT` | `1` | Set to `0` to disable auto-extraction |
|
|
87
157
|
| `OPENCODE_MEMORY_FOREGROUND` | `0` | Set to `1` to run extraction in foreground (debugging) |
|
|
88
|
-
| `OPENCODE_MEMORY_MODEL` | *(default)* | Override model for extraction
|
|
158
|
+
| `OPENCODE_MEMORY_MODEL` | *(default)* | Override model for extraction |
|
|
89
159
|
| `OPENCODE_MEMORY_AGENT` | *(default)* | Override agent for extraction |
|
|
90
160
|
|
|
91
161
|
### Logs
|
|
92
162
|
|
|
93
163
|
Extraction logs are written to `$TMPDIR/opencode-memory-logs/extract-*.log`.
|
|
94
164
|
|
|
95
|
-
### Concurrency
|
|
165
|
+
### Concurrency Safety
|
|
96
166
|
|
|
97
167
|
A file lock prevents multiple extractions from running simultaneously on the same project. Stale locks (from crashed processes) are automatically cleaned up.
|
|
98
168
|
|
|
99
|
-
## Memory Format
|
|
169
|
+
## 📝 Memory Format
|
|
100
170
|
|
|
101
171
|
Each memory is a Markdown file with YAML frontmatter:
|
|
102
172
|
|
|
@@ -113,7 +183,7 @@ Skip post-action summaries. User reads diffs directly.
|
|
|
113
183
|
**How to apply:** Don't summarize changes at the end of responses.
|
|
114
184
|
```
|
|
115
185
|
|
|
116
|
-
### Memory
|
|
186
|
+
### Memory Types
|
|
117
187
|
|
|
118
188
|
| Type | Description |
|
|
119
189
|
|---|---|
|
|
@@ -122,44 +192,19 @@ Skip post-action summaries. User reads diffs directly.
|
|
|
122
192
|
| `project` | Ongoing work context not derivable from code |
|
|
123
193
|
| `reference` | Pointers to external resources |
|
|
124
194
|
|
|
125
|
-
|
|
195
|
+
<details>
|
|
196
|
+
<summary>📄 Index file (MEMORY.md)</summary>
|
|
126
197
|
|
|
127
|
-
`MEMORY.md` is an index (not content storage). Each entry is one line:
|
|
198
|
+
`MEMORY.md` is an auto-managed index (not content storage). Each entry is one line:
|
|
128
199
|
|
|
129
200
|
```markdown
|
|
130
201
|
- [User prefers terse responses](feedback_terse_responses.md) — Skip summaries, user reads diffs
|
|
131
202
|
- [User is a data scientist](user_role.md) — Focus on observability/logging context
|
|
132
203
|
```
|
|
133
204
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
This plugin uses the **exact same path algorithm** as Claude Code:
|
|
137
|
-
|
|
138
|
-
1. Find the canonical git root (resolves worktrees to their main repo)
|
|
139
|
-
2. Sanitize the path with `sanitizePath()` (Claude Code's algorithm, including `djb2Hash` for long paths)
|
|
140
|
-
3. Store in `~/.claude/projects/<sanitized>/memory/`
|
|
141
|
-
|
|
142
|
-
This means:
|
|
143
|
-
- Git worktrees of the same repo share the same memory directory
|
|
144
|
-
- The sanitized path matches Claude Code's output exactly
|
|
145
|
-
- Memory files use the same frontmatter format and type taxonomy
|
|
146
|
-
|
|
147
|
-
## File Structure
|
|
148
|
-
|
|
149
|
-
```
|
|
150
|
-
opencode-memory/
|
|
151
|
-
├── bin/
|
|
152
|
-
│ └── opencode # Drop-in wrapper (finds real binary, adds memory extraction)
|
|
153
|
-
├── src/
|
|
154
|
-
│ ├── index.ts # Plugin entry point (tools + hooks)
|
|
155
|
-
│ ├── memory.ts # Memory CRUD operations
|
|
156
|
-
│ ├── paths.ts # Claude-compatible path resolution
|
|
157
|
-
│ └── prompt.ts # System prompt injection
|
|
158
|
-
├── package.json
|
|
159
|
-
└── tsconfig.json
|
|
160
|
-
```
|
|
205
|
+
</details>
|
|
161
206
|
|
|
162
|
-
## Tools Reference
|
|
207
|
+
## 🔧 Tools Reference
|
|
163
208
|
|
|
164
209
|
### `memory_save`
|
|
165
210
|
|
|
@@ -167,11 +212,11 @@ Save or update a memory.
|
|
|
167
212
|
|
|
168
213
|
| Parameter | Type | Required | Description |
|
|
169
214
|
|---|---|---|---|
|
|
170
|
-
| `file_name` | string |
|
|
171
|
-
| `name` | string |
|
|
172
|
-
| `description` | string |
|
|
173
|
-
| `type` | enum |
|
|
174
|
-
| `content` | string |
|
|
215
|
+
| `file_name` | string | ✅ | File name slug (e.g., `user_role`) |
|
|
216
|
+
| `name` | string | ✅ | Short title |
|
|
217
|
+
| `description` | string | ✅ | One-line description for relevance matching |
|
|
218
|
+
| `type` | enum | ✅ | `user`, `feedback`, `project`, or `reference` |
|
|
219
|
+
| `content` | string | ✅ | Memory content |
|
|
175
220
|
|
|
176
221
|
### `memory_delete`
|
|
177
222
|
|
|
@@ -189,6 +234,6 @@ Search memories by keyword across name, description, and content.
|
|
|
189
234
|
|
|
190
235
|
Read the full content of a specific memory file.
|
|
191
236
|
|
|
192
|
-
## License
|
|
237
|
+
## 📄 License
|
|
193
238
|
|
|
194
|
-
MIT
|
|
239
|
+
[MIT](LICENSE) © [kuitos](https://github.com/kuitos)
|
package/bin/opencode
CHANGED
|
@@ -125,6 +125,22 @@ log() {
|
|
|
125
125
|
echo "[opencode-memory] $*" >&2
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
has_new_memories() {
|
|
129
|
+
# Check if any memory file was modified during the session
|
|
130
|
+
# Checks all projects' memory directories for files newer than the timestamp marker
|
|
131
|
+
local mem_base="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
|
|
132
|
+
|
|
133
|
+
if [ ! -d "$mem_base" ]; then
|
|
134
|
+
return 1
|
|
135
|
+
fi
|
|
136
|
+
|
|
137
|
+
# Find any .md file under projects/*/memory/ newer than our timestamp
|
|
138
|
+
local newer_files
|
|
139
|
+
newer_files=$(find "$mem_base" -path "*/memory/*.md" -newer "$TIMESTAMP_FILE" 2>/dev/null | head -1)
|
|
140
|
+
|
|
141
|
+
[ -n "$newer_files" ]
|
|
142
|
+
}
|
|
143
|
+
|
|
128
144
|
get_latest_session_id() {
|
|
129
145
|
local session_json
|
|
130
146
|
session_json=$("$REAL_OPENCODE" session list --format json -n 1 2>/dev/null) || return 1
|
|
@@ -157,6 +173,10 @@ release_lock() {
|
|
|
157
173
|
rm -f "$LOCK_FILE"
|
|
158
174
|
}
|
|
159
175
|
|
|
176
|
+
cleanup_timestamp() {
|
|
177
|
+
rm -f "$TIMESTAMP_FILE"
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
run_extraction() {
|
|
161
181
|
local session_id="$1"
|
|
162
182
|
|
|
@@ -190,12 +210,16 @@ run_extraction() {
|
|
|
190
210
|
# Main
|
|
191
211
|
# ============================================================================
|
|
192
212
|
|
|
213
|
+
# Step 0: Create timestamp marker before running opencode
|
|
214
|
+
TIMESTAMP_FILE=$(mktemp)
|
|
215
|
+
|
|
193
216
|
# Step 1: Run the real opencode with all original arguments, capture exit code
|
|
194
217
|
opencode_exit=0
|
|
195
218
|
"$REAL_OPENCODE" "$@" || opencode_exit=$?
|
|
196
219
|
|
|
197
220
|
# Step 2: Check if extraction is enabled
|
|
198
221
|
if [ "$EXTRACT_ENABLED" = "0" ]; then
|
|
222
|
+
cleanup_timestamp
|
|
199
223
|
exit $opencode_exit
|
|
200
224
|
fi
|
|
201
225
|
|
|
@@ -204,11 +228,20 @@ session_id=$(get_latest_session_id)
|
|
|
204
228
|
|
|
205
229
|
if [ -z "$session_id" ]; then
|
|
206
230
|
log "No session found, skipping memory extraction"
|
|
231
|
+
cleanup_timestamp
|
|
232
|
+
exit $opencode_exit
|
|
233
|
+
fi
|
|
234
|
+
|
|
235
|
+
# Step 3.5: Check if memories were already written during the session
|
|
236
|
+
if has_new_memories; then
|
|
237
|
+
log "Main agent already wrote memories during session, skipping extraction"
|
|
238
|
+
cleanup_timestamp
|
|
207
239
|
exit $opencode_exit
|
|
208
240
|
fi
|
|
209
241
|
|
|
210
242
|
# Step 4: Acquire lock (prevent concurrent extractions)
|
|
211
243
|
if ! acquire_lock; then
|
|
244
|
+
cleanup_timestamp
|
|
212
245
|
exit $opencode_exit
|
|
213
246
|
fi
|
|
214
247
|
|
|
@@ -216,11 +249,13 @@ fi
|
|
|
216
249
|
if [ "$FOREGROUND" = "1" ]; then
|
|
217
250
|
# Foreground mode (for debugging)
|
|
218
251
|
run_extraction "$session_id"
|
|
252
|
+
cleanup_timestamp
|
|
219
253
|
else
|
|
220
254
|
# Background mode (default) — user isn't blocked
|
|
221
255
|
run_extraction "$session_id" &
|
|
222
256
|
disown
|
|
223
257
|
log "Memory extraction started in background (PID $!)"
|
|
258
|
+
cleanup_timestamp
|
|
224
259
|
fi
|
|
225
260
|
|
|
226
261
|
exit $opencode_exit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-claude-memory",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Cross-session memory plugin for OpenCode — Claude Code compatible, persistent, file-based memory",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -33,5 +33,9 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"zod": "^3.24.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"bun-types": "^1.3.11",
|
|
39
|
+
"@opencode-ai/plugin": "^1.3.10"
|
|
36
40
|
}
|
|
37
41
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { tool } from "@opencode-ai/plugin"
|
|
3
3
|
import { buildMemorySystemPrompt } from "./prompt.js"
|
|
4
|
+
import { recallRelevantMemories, formatRecalledMemories } from "./recall.js"
|
|
4
5
|
import {
|
|
5
6
|
saveMemory,
|
|
6
7
|
deleteMemory,
|
|
7
8
|
listMemories,
|
|
8
9
|
searchMemories,
|
|
9
10
|
readMemory,
|
|
10
|
-
readIndex,
|
|
11
11
|
MEMORY_TYPES,
|
|
12
|
-
type MemoryType,
|
|
13
12
|
} from "./memory.js"
|
|
14
13
|
import { getMemoryDir } from "./paths.js"
|
|
15
14
|
|
|
@@ -18,7 +17,26 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
|
|
|
18
17
|
|
|
19
18
|
return {
|
|
20
19
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
21
|
-
|
|
20
|
+
let query: string | undefined
|
|
21
|
+
if (_input && typeof _input === "object") {
|
|
22
|
+
const messages = (_input as { messages?: unknown }).messages
|
|
23
|
+
if (Array.isArray(messages)) {
|
|
24
|
+
const lastUserMsg = [...messages]
|
|
25
|
+
.reverse()
|
|
26
|
+
.find((message) =>
|
|
27
|
+
message && typeof message === "object" && "role" in message && (message as { role?: unknown }).role === "user",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if (lastUserMsg && typeof lastUserMsg === "object" && "content" in lastUserMsg) {
|
|
31
|
+
const content = (lastUserMsg as { content?: unknown }).content
|
|
32
|
+
query = typeof content === "string" ? content : JSON.stringify(content)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const recalled = recallRelevantMemories(worktree, query)
|
|
38
|
+
const recalledSection = formatRecalledMemories(recalled)
|
|
39
|
+
const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection)
|
|
22
40
|
output.system.push(memoryPrompt)
|
|
23
41
|
},
|
|
24
42
|
|
package/src/memory.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "fs"
|
|
2
2
|
import { join, basename } from "path"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getMemoryDir,
|
|
5
|
+
getMemoryEntrypoint,
|
|
6
|
+
ENTRYPOINT_NAME,
|
|
7
|
+
validateMemoryFileName,
|
|
8
|
+
MAX_MEMORY_FILES,
|
|
9
|
+
MAX_MEMORY_FILE_BYTES,
|
|
10
|
+
FRONTMATTER_MAX_LINES,
|
|
11
|
+
} from "./paths.js"
|
|
4
12
|
|
|
5
13
|
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"] as const
|
|
6
14
|
export type MemoryType = (typeof MEMORY_TYPES)[number]
|
|
@@ -21,11 +29,20 @@ function parseFrontmatter(raw: string): { frontmatter: Record<string, string>; c
|
|
|
21
29
|
return { frontmatter: {}, content: trimmed }
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
const
|
|
25
|
-
|
|
32
|
+
const lines = trimmed.split("\n")
|
|
33
|
+
let closingLineIdx = -1
|
|
34
|
+
for (let i = 1; i < Math.min(lines.length, FRONTMATTER_MAX_LINES); i++) {
|
|
35
|
+
if (lines[i].trimEnd() === "---") {
|
|
36
|
+
closingLineIdx = i
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (closingLineIdx === -1) {
|
|
26
41
|
return { frontmatter: {}, content: trimmed }
|
|
27
42
|
}
|
|
28
43
|
|
|
44
|
+
const endIndex = lines.slice(0, closingLineIdx).join("\n").length + 1
|
|
45
|
+
|
|
29
46
|
const frontmatterBlock = trimmed.slice(3, endIndex).trim()
|
|
30
47
|
const content = trimmed.slice(endIndex + 3).trim()
|
|
31
48
|
|
|
@@ -61,6 +78,7 @@ export function listMemories(worktree: string): MemoryEntry[] {
|
|
|
61
78
|
files = readdirSync(memDir)
|
|
62
79
|
.filter((f) => f.endsWith(".md") && f !== ENTRYPOINT_NAME)
|
|
63
80
|
.sort()
|
|
81
|
+
.slice(0, MAX_MEMORY_FILES)
|
|
64
82
|
} catch {
|
|
65
83
|
return entries
|
|
66
84
|
}
|
|
@@ -88,8 +106,9 @@ export function listMemories(worktree: string): MemoryEntry[] {
|
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
export function readMemory(worktree: string, fileName: string): MemoryEntry | null {
|
|
109
|
+
const safeName = validateMemoryFileName(fileName)
|
|
91
110
|
const memDir = getMemoryDir(worktree)
|
|
92
|
-
const filePath = join(memDir,
|
|
111
|
+
const filePath = join(memDir, safeName)
|
|
93
112
|
|
|
94
113
|
try {
|
|
95
114
|
const rawContent = readFileSync(filePath, "utf-8")
|
|
@@ -116,11 +135,16 @@ export function saveMemory(
|
|
|
116
135
|
type: MemoryType,
|
|
117
136
|
content: string,
|
|
118
137
|
): string {
|
|
138
|
+
const safeName = validateMemoryFileName(fileName)
|
|
119
139
|
const memDir = getMemoryDir(worktree)
|
|
120
|
-
const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
|
|
121
140
|
const filePath = join(memDir, safeName)
|
|
122
141
|
|
|
123
142
|
const fileContent = `${buildFrontmatter(name, description, type)}\n\n${content.trim()}\n`
|
|
143
|
+
if (Buffer.byteLength(fileContent, "utf-8") > MAX_MEMORY_FILE_BYTES) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Memory file content exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
124
148
|
writeFileSync(filePath, fileContent, "utf-8")
|
|
125
149
|
|
|
126
150
|
updateIndex(worktree, safeName, name, description)
|
|
@@ -129,8 +153,8 @@ export function saveMemory(
|
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
export function deleteMemory(worktree: string, fileName: string): boolean {
|
|
156
|
+
const safeName = validateMemoryFileName(fileName)
|
|
132
157
|
const memDir = getMemoryDir(worktree)
|
|
133
|
-
const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
|
|
134
158
|
const filePath = join(memDir, safeName)
|
|
135
159
|
|
|
136
160
|
try {
|
package/src/paths.ts
CHANGED
|
@@ -10,6 +10,35 @@ export const ENTRYPOINT_NAME = "MEMORY.md"
|
|
|
10
10
|
export const MAX_ENTRYPOINT_LINES = 200
|
|
11
11
|
export const MAX_ENTRYPOINT_BYTES = 25_000
|
|
12
12
|
|
|
13
|
+
export const MAX_MEMORY_FILES = 200
|
|
14
|
+
export const MAX_MEMORY_FILE_BYTES = 40_000
|
|
15
|
+
export const FRONTMATTER_MAX_LINES = 30
|
|
16
|
+
|
|
17
|
+
export function validateMemoryFileName(fileName: string): string {
|
|
18
|
+
const base = fileName.endsWith(".md") ? fileName.slice(0, -3) : fileName
|
|
19
|
+
|
|
20
|
+
if (base.length === 0) {
|
|
21
|
+
throw new Error("Memory file name cannot be empty")
|
|
22
|
+
}
|
|
23
|
+
if (base.includes("/") || base.includes("\\")) {
|
|
24
|
+
throw new Error(`Memory file name must not contain path separators: ${fileName}`)
|
|
25
|
+
}
|
|
26
|
+
if (base.includes("..")) {
|
|
27
|
+
throw new Error(`Memory file name must not contain path traversal: ${fileName}`)
|
|
28
|
+
}
|
|
29
|
+
if (base.includes("\0")) {
|
|
30
|
+
throw new Error(`Memory file name must not contain null bytes: ${fileName}`)
|
|
31
|
+
}
|
|
32
|
+
if (base.startsWith(".")) {
|
|
33
|
+
throw new Error(`Memory file name must not start with '.': ${fileName}`)
|
|
34
|
+
}
|
|
35
|
+
if (base.toUpperCase() === "MEMORY") {
|
|
36
|
+
throw new Error(`'MEMORY' is a reserved name and cannot be used as a memory file name`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return `${base}.md`
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
const MAX_SANITIZED_LENGTH = 200
|
|
14
43
|
|
|
15
44
|
// Exact copy of Claude Code's djb2Hash() from utils/hash.ts
|
package/src/prompt.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MEMORY_TYPES } from "./memory.js"
|
|
2
|
-
import { readIndex, truncateEntrypoint
|
|
2
|
+
import { readIndex, truncateEntrypoint } from "./memory.js"
|
|
3
3
|
import { getMemoryDir, ENTRYPOINT_NAME } from "./paths.js"
|
|
4
4
|
|
|
5
5
|
const FRONTMATTER_EXAMPLE = `\`\`\`markdown
|
|
@@ -89,7 +89,7 @@ A memory that names a specific function, file, or flag is a claim that it existe
|
|
|
89
89
|
|
|
90
90
|
A memory that summarizes repo state is frozen in time. If the user asks about *recent* or *current* state, prefer \`git log\` or reading the code over recalling the snapshot.`
|
|
91
91
|
|
|
92
|
-
export function buildMemorySystemPrompt(worktree: string): string {
|
|
92
|
+
export function buildMemorySystemPrompt(worktree: string, recalledMemoriesSection?: string): string {
|
|
93
93
|
const memoryDir = getMemoryDir(worktree)
|
|
94
94
|
const indexContent = readIndex(worktree)
|
|
95
95
|
|
|
@@ -139,5 +139,9 @@ export function buildMemorySystemPrompt(worktree: string): string {
|
|
|
139
139
|
)
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
if (recalledMemoriesSection?.trim()) {
|
|
143
|
+
lines.push("", recalledMemoriesSection)
|
|
144
|
+
}
|
|
145
|
+
|
|
142
146
|
return lines.join("\n")
|
|
143
147
|
}
|
package/src/recall.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { statSync } from "fs"
|
|
2
|
+
import { listMemories, type MemoryEntry } from "./memory.js"
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder()
|
|
5
|
+
|
|
6
|
+
export type RecalledMemory = {
|
|
7
|
+
fileName: string
|
|
8
|
+
name: string
|
|
9
|
+
type: string
|
|
10
|
+
description: string
|
|
11
|
+
content: string
|
|
12
|
+
ageInDays: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_RECALLED_MEMORIES = 5
|
|
16
|
+
const MAX_MEMORY_LINES = 200
|
|
17
|
+
const MAX_MEMORY_BYTES = 4096
|
|
18
|
+
|
|
19
|
+
function tokenizeQuery(query: string): string[] {
|
|
20
|
+
return [...new Set(query.toLowerCase().split(/\s+/).map((token) => token.trim()).filter((token) => token.length >= 2))]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getMemoryMtimeMs(entry: MemoryEntry): number {
|
|
24
|
+
try {
|
|
25
|
+
return statSync(entry.filePath).mtimeMs
|
|
26
|
+
} catch {
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scoreMemory(entry: MemoryEntry, terms: string[]): number {
|
|
32
|
+
if (terms.length === 0) return 0
|
|
33
|
+
const haystack = `${entry.name}\n${entry.description}\n${entry.content}`.toLowerCase()
|
|
34
|
+
let score = 0
|
|
35
|
+
for (const term of terms) {
|
|
36
|
+
if (haystack.includes(term)) score += 1
|
|
37
|
+
}
|
|
38
|
+
return score
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncateMemoryContent(content: string): string {
|
|
42
|
+
const maxLines = content.split("\n").slice(0, MAX_MEMORY_LINES)
|
|
43
|
+
const lineTruncated = maxLines.join("\n")
|
|
44
|
+
if (encoder.encode(lineTruncated).length <= MAX_MEMORY_BYTES) {
|
|
45
|
+
return lineTruncated
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lines = lineTruncated.split("\n")
|
|
49
|
+
const kept: string[] = []
|
|
50
|
+
let usedBytes = 0
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const candidate = kept.length === 0 ? line : `\n${line}`
|
|
54
|
+
const candidateBytes = encoder.encode(candidate).length
|
|
55
|
+
if (usedBytes + candidateBytes > MAX_MEMORY_BYTES) break
|
|
56
|
+
kept.push(line)
|
|
57
|
+
usedBytes += candidateBytes
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return kept.join("\n")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function recallRelevantMemories(worktree: string, query?: string): RecalledMemory[] {
|
|
64
|
+
const memories = listMemories(worktree)
|
|
65
|
+
if (memories.length === 0) return []
|
|
66
|
+
|
|
67
|
+
const now = Date.now()
|
|
68
|
+
const memoriesWithMeta = memories.map((entry) => {
|
|
69
|
+
const mtimeMs = getMemoryMtimeMs(entry)
|
|
70
|
+
return {
|
|
71
|
+
entry,
|
|
72
|
+
mtimeMs,
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const terms = query ? tokenizeQuery(query) : []
|
|
77
|
+
|
|
78
|
+
let selected = memoriesWithMeta
|
|
79
|
+
|
|
80
|
+
if (terms.length > 0) {
|
|
81
|
+
const withScores = memoriesWithMeta
|
|
82
|
+
.map((item) => ({
|
|
83
|
+
...item,
|
|
84
|
+
score: scoreMemory(item.entry, terms),
|
|
85
|
+
}))
|
|
86
|
+
.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs)
|
|
87
|
+
|
|
88
|
+
if (withScores.some((item) => item.score > 0)) {
|
|
89
|
+
selected = withScores
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (selected === memoriesWithMeta) {
|
|
94
|
+
selected = [...memoriesWithMeta].sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return selected.slice(0, MAX_RECALLED_MEMORIES).map(({ entry, mtimeMs }) => ({
|
|
98
|
+
fileName: entry.fileName,
|
|
99
|
+
name: entry.name,
|
|
100
|
+
type: entry.type,
|
|
101
|
+
description: entry.description,
|
|
102
|
+
content: truncateMemoryContent(entry.content),
|
|
103
|
+
ageInDays: Math.max(0, Math.floor((now - mtimeMs) / (1000 * 60 * 60 * 24))),
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatAgeWarning(ageInDays: number): string {
|
|
108
|
+
if (ageInDays <= 1) return ""
|
|
109
|
+
return `\n> ⚠️ This memory is ${ageInDays} days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact.\n`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatRecalledMemories(memories: RecalledMemory[]): string {
|
|
113
|
+
if (memories.length === 0) return ""
|
|
114
|
+
|
|
115
|
+
const sections = memories.map((memory) => {
|
|
116
|
+
const ageWarning = formatAgeWarning(memory.ageInDays)
|
|
117
|
+
return `### ${memory.name} (${memory.type})${ageWarning}\n${memory.content}`
|
|
118
|
+
})
|
|
119
|
+
return [
|
|
120
|
+
"## Recalled Memories",
|
|
121
|
+
"",
|
|
122
|
+
"The following memories were automatically selected as relevant to this conversation. They may be outdated — verify against current state before relying on them.",
|
|
123
|
+
"",
|
|
124
|
+
sections.join("\n\n"),
|
|
125
|
+
].join("\n")
|
|
126
|
+
}
|