opencode-claude-memory 1.0.0 β 1.2.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 +97 -146
- package/bin/{opencode β opencode-memory} +159 -24
- package/package.json +7 -3
- 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/README.md
CHANGED
|
@@ -1,69 +1,29 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
# π§
|
|
3
|
+
# π§ Claude Code-compatible memory for OpenCode
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Make OpenCode and Claude Code share the same memory β zero config, local-first, and no migration required.**
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Claude Code writes memories β OpenCode reads them. OpenCode writes memories β Claude Code reads them.
|
|
7
|
+
Claude Code writes memory β OpenCode reads it. OpenCode writes memory β Claude Code reads it.
|
|
10
8
|
|
|
11
9
|
[](https://www.npmjs.com/package/opencode-claude-memory)
|
|
12
10
|
[](https://www.npmjs.com/package/opencode-claude-memory)
|
|
13
11
|
[](https://github.com/kuitos/opencode-claude-memory/blob/main/LICENSE)
|
|
14
12
|
|
|
15
|
-
[
|
|
13
|
+
[Quick Start](#-quick-start) β’ [Why this exists](#-why-this-exists) β’ [What makes this different](#-what-makes-this-different) β’ [How it works](#-how-it-works) β’ [Who this is for](#-who-this-is-for) β’ [FAQ](#-faq)
|
|
16
14
|
|
|
17
15
|
</div>
|
|
18
16
|
|
|
19
17
|
---
|
|
20
18
|
|
|
21
|
-
## β¨
|
|
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
|
|
19
|
+
## β¨ At a glance
|
|
63
20
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
21
|
+
- **Claude Code-compatible memory**
|
|
22
|
+
Uses Claude Codeβs existing memory paths, file format, and taxonomy.
|
|
23
|
+
- **Zero config**
|
|
24
|
+
Install + enable plugin, then keep using `opencode` as usual.
|
|
25
|
+
- **Local-first, no migration**
|
|
26
|
+
Memory stays as local Markdown files in the same directory Claude Code already uses.
|
|
67
27
|
|
|
68
28
|
## π Quick Start
|
|
69
29
|
|
|
@@ -71,11 +31,13 @@ Worktrees of the same repo share the same memory directory
|
|
|
71
31
|
|
|
72
32
|
```bash
|
|
73
33
|
npm install -g opencode-claude-memory
|
|
34
|
+
opencode-memory install # one-time: installs shell hook
|
|
74
35
|
```
|
|
75
36
|
|
|
76
37
|
This installs:
|
|
77
38
|
- The **plugin** β memory tools + system prompt injection
|
|
78
|
-
-
|
|
39
|
+
- The `opencode-memory` **CLI** β wraps opencode with post-session memory extraction
|
|
40
|
+
- A **shell hook** β defines an `opencode()` function in your `.zshrc`/`.bashrc` that delegates to `opencode-memory`
|
|
79
41
|
|
|
80
42
|
### 2. Configure
|
|
81
43
|
|
|
@@ -89,84 +51,110 @@ This installs:
|
|
|
89
51
|
### 3. Use
|
|
90
52
|
|
|
91
53
|
```bash
|
|
92
|
-
opencode
|
|
54
|
+
opencode
|
|
93
55
|
```
|
|
94
56
|
|
|
95
|
-
|
|
57
|
+
Thatβs it. Memory extraction runs in the background after each session.
|
|
96
58
|
|
|
97
|
-
|
|
98
|
-
- **"What do you remember about me?"** β reads from memory
|
|
99
|
-
- **"Forget the memory about my role"** β deletes a memory
|
|
100
|
-
|
|
101
|
-
When you exit, memories are extracted in the background β zero blocking.
|
|
102
|
-
|
|
103
|
-
<details>
|
|
104
|
-
<summary>ποΈ Uninstall</summary>
|
|
59
|
+
To uninstall:
|
|
105
60
|
|
|
106
61
|
```bash
|
|
62
|
+
opencode-memory uninstall # removes shell hook from .zshrc/.bashrc
|
|
107
63
|
npm uninstall -g opencode-claude-memory
|
|
108
64
|
```
|
|
109
65
|
|
|
110
|
-
This removes the
|
|
66
|
+
This removes the shell hook, the CLI, and the plugin. Your saved memories in `~/.claude/projects/` are **not** deleted.
|
|
67
|
+
|
|
68
|
+
## π‘ Why this exists
|
|
69
|
+
|
|
70
|
+
If you use both Claude Code and OpenCode on the same repository, memory often ends up in separate silos.
|
|
71
|
+
|
|
72
|
+
This project solves that by making OpenCode read and write memory in Claude Codeβs existing structure, so your context carries over naturally between both tools.
|
|
73
|
+
|
|
74
|
+
## π§© What makes this different
|
|
111
75
|
|
|
112
|
-
|
|
76
|
+
Most memory plugins introduce a new storage model or migration step.
|
|
113
77
|
|
|
114
|
-
|
|
78
|
+
This one is a **compatibility layer**, not a new memory system:
|
|
79
|
+
|
|
80
|
+
- same memory directory conventions as Claude Code
|
|
81
|
+
- same Markdown + frontmatter format
|
|
82
|
+
- same memory taxonomy (`user`, `feedback`, `project`, `reference`)
|
|
83
|
+
- same project/worktree resolution behavior
|
|
84
|
+
|
|
85
|
+
The outcome: **shared context across Claude Code and OpenCode without maintaining two memory systems.**
|
|
86
|
+
|
|
87
|
+
## βοΈ How it works
|
|
115
88
|
|
|
116
89
|
```mermaid
|
|
117
90
|
graph LR
|
|
118
|
-
A[You run opencode] --> B[
|
|
119
|
-
B --> C[
|
|
120
|
-
C --> D[
|
|
121
|
-
D --> E[
|
|
91
|
+
A[You run opencode] --> B[Shell hook calls opencode-memory]
|
|
92
|
+
B --> C[opencode-memory finds real binary]
|
|
93
|
+
C --> D[Runs opencode normally]
|
|
94
|
+
D --> E[You exit]
|
|
122
95
|
E --> F[Fork session + extract memories]
|
|
123
96
|
F --> G[Memories saved to ~/.claude/projects/]
|
|
124
97
|
```
|
|
125
98
|
|
|
126
|
-
The
|
|
99
|
+
The shell hook defines an `opencode()` function that delegates to `opencode-memory`:
|
|
100
|
+
|
|
101
|
+
1. Shell function intercepts `opencode` command (higher priority than PATH)
|
|
102
|
+
2. `opencode-memory` finds the real `opencode` binary in PATH
|
|
103
|
+
3. Runs it with all your arguments
|
|
104
|
+
4. After you exit, forks the session with a memory extraction prompt
|
|
105
|
+
5. Extraction runs **in the background** β you're never blocked
|
|
106
|
+
|
|
107
|
+
### Compatibility details
|
|
108
|
+
|
|
109
|
+
The implementation ports core logic from Claude Code for path hashing, git-root/worktree handling, memory format, and memory prompting behavior, so both tools can operate on the same files safely.
|
|
127
110
|
|
|
128
|
-
|
|
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
|
|
111
|
+
## π₯ Who this is for
|
|
132
112
|
|
|
133
|
-
|
|
113
|
+
- You use **both Claude Code and OpenCode**.
|
|
114
|
+
- You want **one shared memory context** across both tools.
|
|
115
|
+
- You prefer **file-based, local-first memory** you can inspect in Git/worktrees.
|
|
116
|
+
- You donβt want migration overhead or lock-in.
|
|
134
117
|
|
|
135
|
-
|
|
118
|
+
## β FAQ
|
|
136
119
|
|
|
137
|
-
|
|
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 |
|
|
120
|
+
### Is this a new memory system?
|
|
144
121
|
|
|
145
|
-
|
|
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
|
|
122
|
+
No. It is a compatibility layer that lets OpenCode use Claude Code-compatible memory layout and conventions.
|
|
149
123
|
|
|
150
|
-
|
|
124
|
+
### Do I need to migrate existing memory?
|
|
151
125
|
|
|
152
|
-
|
|
126
|
+
No migration required. If you already have Claude Code memory files, OpenCode can work with them directly.
|
|
153
127
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
128
|
+
### Where is data stored?
|
|
129
|
+
|
|
130
|
+
In local files under Claude-style project memory directories (for example, under `~/.claude/projects/<project>/memory/`).
|
|
131
|
+
|
|
132
|
+
### Why file-based memory?
|
|
133
|
+
|
|
134
|
+
File-based memory is transparent, local-first, easy to inspect/diff/back up, and works naturally with existing developer workflows.
|
|
135
|
+
|
|
136
|
+
### Can I disable auto extraction?
|
|
137
|
+
|
|
138
|
+
Yes. Set `OPENCODE_MEMORY_EXTRACT=0`.
|
|
139
|
+
|
|
140
|
+
## π§ Configuration
|
|
141
|
+
|
|
142
|
+
### Environment variables
|
|
143
|
+
|
|
144
|
+
- `OPENCODE_MEMORY_EXTRACT` (default `1`): set `0` to disable auto-extraction
|
|
145
|
+
- `OPENCODE_MEMORY_FOREGROUND` (default `0`): set `1` to run extraction in foreground
|
|
146
|
+
- `OPENCODE_MEMORY_MODEL`: override model used for extraction
|
|
147
|
+
- `OPENCODE_MEMORY_AGENT`: override agent used for extraction
|
|
160
148
|
|
|
161
149
|
### Logs
|
|
162
150
|
|
|
163
151
|
Extraction logs are written to `$TMPDIR/opencode-memory-logs/extract-*.log`.
|
|
164
152
|
|
|
165
|
-
### Concurrency
|
|
153
|
+
### Concurrency safety
|
|
166
154
|
|
|
167
|
-
A file lock prevents multiple extractions from running simultaneously on the same project. Stale locks
|
|
155
|
+
A file lock prevents multiple extractions from running simultaneously on the same project. Stale locks are cleaned up automatically.
|
|
168
156
|
|
|
169
|
-
## π Memory
|
|
157
|
+
## π Memory format
|
|
170
158
|
|
|
171
159
|
Each memory is a Markdown file with YAML frontmatter:
|
|
172
160
|
|
|
@@ -183,56 +171,19 @@ Skip post-action summaries. User reads diffs directly.
|
|
|
183
171
|
**How to apply:** Don't summarize changes at the end of responses.
|
|
184
172
|
```
|
|
185
173
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
| `feedback` | Guidance on how to work (corrections and confirmations) |
|
|
192
|
-
| `project` | Ongoing work context not derivable from code |
|
|
193
|
-
| `reference` | Pointers to external resources |
|
|
194
|
-
|
|
195
|
-
<details>
|
|
196
|
-
<summary>π Index file (MEMORY.md)</summary>
|
|
197
|
-
|
|
198
|
-
`MEMORY.md` is an auto-managed index (not content storage). Each entry is one line:
|
|
199
|
-
|
|
200
|
-
```markdown
|
|
201
|
-
- [User prefers terse responses](feedback_terse_responses.md) β Skip summaries, user reads diffs
|
|
202
|
-
- [User is a data scientist](user_role.md) β Focus on observability/logging context
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
</details>
|
|
206
|
-
|
|
207
|
-
## π§ Tools Reference
|
|
208
|
-
|
|
209
|
-
### `memory_save`
|
|
210
|
-
|
|
211
|
-
Save or update a memory.
|
|
212
|
-
|
|
213
|
-
| Parameter | Type | Required | Description |
|
|
214
|
-
|---|---|---|---|
|
|
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 |
|
|
220
|
-
|
|
221
|
-
### `memory_delete`
|
|
222
|
-
|
|
223
|
-
Delete a memory by file name.
|
|
224
|
-
|
|
225
|
-
### `memory_list`
|
|
226
|
-
|
|
227
|
-
List all memories with their metadata.
|
|
228
|
-
|
|
229
|
-
### `memory_search`
|
|
230
|
-
|
|
231
|
-
Search memories by keyword across name, description, and content.
|
|
174
|
+
Supported memory types:
|
|
175
|
+
- `user`
|
|
176
|
+
- `feedback`
|
|
177
|
+
- `project`
|
|
178
|
+
- `reference`
|
|
232
179
|
|
|
233
|
-
|
|
180
|
+
## π§ Tools reference
|
|
234
181
|
|
|
235
|
-
|
|
182
|
+
- `memory_save`: save/update a memory
|
|
183
|
+
- `memory_delete`: delete a memory by filename
|
|
184
|
+
- `memory_list`: list memory metadata
|
|
185
|
+
- `memory_search`: search by keyword
|
|
186
|
+
- `memory_read`: read full memory content
|
|
236
187
|
|
|
237
188
|
## π License
|
|
238
189
|
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# opencode
|
|
4
|
-
# binary, then automatically extracts and saves memories after the session ends.
|
|
3
|
+
# opencode-memory β Wrapper for OpenCode with automatic memory extraction.
|
|
5
4
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Installs a shell hook (function) that intercepts the `opencode` command,
|
|
6
|
+
# then wraps the real binary with post-session memory extraction.
|
|
8
7
|
#
|
|
9
|
-
#
|
|
10
|
-
# opencode
|
|
8
|
+
# Subcommands:
|
|
9
|
+
# opencode-memory install β Install shell hook to ~/.zshrc or ~/.bashrc
|
|
10
|
+
# opencode-memory uninstall β Remove shell hook
|
|
11
|
+
# opencode-memory [args...] β Run opencode with memory extraction
|
|
11
12
|
#
|
|
12
13
|
# How it works:
|
|
13
|
-
# 1.
|
|
14
|
-
# 2.
|
|
15
|
-
# 3.
|
|
16
|
-
# 4.
|
|
17
|
-
# 5.
|
|
14
|
+
# 1. Shell hook defines `opencode()` function that delegates to `opencode-memory`
|
|
15
|
+
# 2. `opencode-memory` finds the real `opencode` binary in PATH
|
|
16
|
+
# 3. Runs it normally with all your arguments
|
|
17
|
+
# 4. After you exit, finds the most recent session
|
|
18
|
+
# 5. Forks that session and sends a memory extraction prompt
|
|
19
|
+
# 6. The extraction runs in the background so you're not blocked
|
|
18
20
|
#
|
|
19
21
|
# Requirements:
|
|
20
|
-
# - Real `opencode` CLI reachable in PATH
|
|
22
|
+
# - Real `opencode` CLI reachable in PATH
|
|
21
23
|
# - `jq` for JSON parsing
|
|
22
24
|
# - The opencode-memory plugin installed (provides memory_save tool)
|
|
23
25
|
#
|
|
@@ -32,23 +34,121 @@
|
|
|
32
34
|
set -euo pipefail
|
|
33
35
|
|
|
34
36
|
# ============================================================================
|
|
35
|
-
#
|
|
37
|
+
# Shell Hook Management
|
|
36
38
|
# ============================================================================
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
HOOK_START_MARKER='# >>> opencode-memory auto-initialization >>>'
|
|
41
|
+
HOOK_END_MARKER='# <<< opencode-memory auto-initialization <<<'
|
|
42
|
+
|
|
43
|
+
detect_shell_rc() {
|
|
44
|
+
local shell_name
|
|
45
|
+
shell_name="$(basename "${SHELL:-}")"
|
|
46
|
+
|
|
47
|
+
case "$shell_name" in
|
|
48
|
+
zsh)
|
|
49
|
+
echo "$HOME/.zshrc"
|
|
50
|
+
;;
|
|
51
|
+
bash)
|
|
52
|
+
echo "$HOME/.bashrc"
|
|
53
|
+
;;
|
|
54
|
+
*)
|
|
55
|
+
if [ -f "$HOME/.zshrc" ]; then
|
|
56
|
+
echo "$HOME/.zshrc"
|
|
57
|
+
elif [ -f "$HOME/.bashrc" ]; then
|
|
58
|
+
echo "$HOME/.bashrc"
|
|
59
|
+
else
|
|
60
|
+
echo "$HOME/.zshrc"
|
|
61
|
+
fi
|
|
62
|
+
;;
|
|
63
|
+
esac
|
|
64
|
+
}
|
|
39
65
|
|
|
40
|
-
|
|
41
|
-
local
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
66
|
+
install_hook() {
|
|
67
|
+
local rc_file
|
|
68
|
+
rc_file=$(detect_shell_rc)
|
|
69
|
+
|
|
70
|
+
if grep -qF "$HOOK_START_MARKER" "$rc_file" 2>/dev/null; then
|
|
71
|
+
echo "[opencode-memory] Hook already installed in $rc_file"
|
|
72
|
+
return 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
cat >> "$rc_file" << 'HOOK'
|
|
76
|
+
|
|
77
|
+
# >>> opencode-memory auto-initialization >>>
|
|
78
|
+
opencode() {
|
|
79
|
+
command opencode-memory "$@"
|
|
80
|
+
}
|
|
81
|
+
# <<< opencode-memory auto-initialization <<<
|
|
82
|
+
HOOK
|
|
83
|
+
|
|
84
|
+
echo "[opencode-memory] Shell hook installed in $rc_file"
|
|
85
|
+
echo "[opencode-memory] Restart your shell or run: source $rc_file"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
remove_hook_from_rc() {
|
|
89
|
+
local rc_file="$1"
|
|
90
|
+
local tmp_file
|
|
91
|
+
tmp_file=$(mktemp)
|
|
92
|
+
|
|
93
|
+
awk -v start="$HOOK_START_MARKER" -v end="$HOOK_END_MARKER" '
|
|
94
|
+
$0 == start { skip=1; next }
|
|
95
|
+
$0 == end { skip=0; next }
|
|
96
|
+
!skip
|
|
97
|
+
' "$rc_file" > "$tmp_file"
|
|
98
|
+
|
|
99
|
+
mv "$tmp_file" "$rc_file"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
uninstall_hook() {
|
|
103
|
+
local removed=0
|
|
104
|
+
local rc_file
|
|
105
|
+
|
|
106
|
+
for rc_file in "$HOME/.zshrc" "$HOME/.bashrc"; do
|
|
107
|
+
[ -f "$rc_file" ] || continue
|
|
108
|
+
|
|
109
|
+
if grep -qF "$HOOK_START_MARKER" "$rc_file" 2>/dev/null; then
|
|
110
|
+
remove_hook_from_rc "$rc_file"
|
|
111
|
+
echo "[opencode-memory] Shell hook removed from $rc_file"
|
|
112
|
+
removed=1
|
|
47
113
|
fi
|
|
48
114
|
done
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
115
|
+
|
|
116
|
+
if [ "$removed" -eq 0 ]; then
|
|
117
|
+
rc_file=$(detect_shell_rc)
|
|
118
|
+
echo "[opencode-memory] Hook not found in $rc_file"
|
|
119
|
+
return 0
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
echo "[opencode-memory] Restart your shell or run: source <your rc file>"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Handle subcommands before any opencode resolution
|
|
126
|
+
case "${1:-}" in
|
|
127
|
+
install)
|
|
128
|
+
install_hook
|
|
129
|
+
exit 0
|
|
130
|
+
;;
|
|
131
|
+
uninstall)
|
|
132
|
+
uninstall_hook
|
|
133
|
+
exit 0
|
|
134
|
+
;;
|
|
135
|
+
esac
|
|
136
|
+
|
|
137
|
+
# ============================================================================
|
|
138
|
+
# Resolve the real opencode binary
|
|
139
|
+
# ============================================================================
|
|
140
|
+
|
|
141
|
+
find_real_opencode() {
|
|
142
|
+
# Since this script is named `opencode-memory` (not `opencode`),
|
|
143
|
+
# `command -v opencode` finds the real binary without ambiguity.
|
|
144
|
+
local real
|
|
145
|
+
real=$(command -v opencode 2>/dev/null) || true
|
|
146
|
+
if [ -z "$real" ] || [ ! -x "$real" ]; then
|
|
147
|
+
echo "[opencode-memory] ERROR: Cannot find opencode binary in PATH" >&2
|
|
148
|
+
echo "[opencode-memory] Make sure opencode is installed: https://opencode.ai" >&2
|
|
149
|
+
exit 1
|
|
150
|
+
fi
|
|
151
|
+
echo "$real"
|
|
52
152
|
}
|
|
53
153
|
|
|
54
154
|
REAL_OPENCODE="$(find_real_opencode)"
|
|
@@ -125,6 +225,22 @@ log() {
|
|
|
125
225
|
echo "[opencode-memory] $*" >&2
|
|
126
226
|
}
|
|
127
227
|
|
|
228
|
+
has_new_memories() {
|
|
229
|
+
# Check if any memory file was modified during the session
|
|
230
|
+
# Checks all projects' memory directories for files newer than the timestamp marker
|
|
231
|
+
local mem_base="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
|
|
232
|
+
|
|
233
|
+
if [ ! -d "$mem_base" ]; then
|
|
234
|
+
return 1
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
# Find any .md file under projects/*/memory/ newer than our timestamp
|
|
238
|
+
local newer_files
|
|
239
|
+
newer_files=$(find "$mem_base" -path "*/memory/*.md" -newer "$TIMESTAMP_FILE" 2>/dev/null | head -1)
|
|
240
|
+
|
|
241
|
+
[ -n "$newer_files" ]
|
|
242
|
+
}
|
|
243
|
+
|
|
128
244
|
get_latest_session_id() {
|
|
129
245
|
local session_json
|
|
130
246
|
session_json=$("$REAL_OPENCODE" session list --format json -n 1 2>/dev/null) || return 1
|
|
@@ -157,6 +273,10 @@ release_lock() {
|
|
|
157
273
|
rm -f "$LOCK_FILE"
|
|
158
274
|
}
|
|
159
275
|
|
|
276
|
+
cleanup_timestamp() {
|
|
277
|
+
rm -f "$TIMESTAMP_FILE"
|
|
278
|
+
}
|
|
279
|
+
|
|
160
280
|
run_extraction() {
|
|
161
281
|
local session_id="$1"
|
|
162
282
|
|
|
@@ -190,12 +310,16 @@ run_extraction() {
|
|
|
190
310
|
# Main
|
|
191
311
|
# ============================================================================
|
|
192
312
|
|
|
313
|
+
# Step 0: Create timestamp marker before running opencode
|
|
314
|
+
TIMESTAMP_FILE=$(mktemp)
|
|
315
|
+
|
|
193
316
|
# Step 1: Run the real opencode with all original arguments, capture exit code
|
|
194
317
|
opencode_exit=0
|
|
195
318
|
"$REAL_OPENCODE" "$@" || opencode_exit=$?
|
|
196
319
|
|
|
197
320
|
# Step 2: Check if extraction is enabled
|
|
198
321
|
if [ "$EXTRACT_ENABLED" = "0" ]; then
|
|
322
|
+
cleanup_timestamp
|
|
199
323
|
exit $opencode_exit
|
|
200
324
|
fi
|
|
201
325
|
|
|
@@ -204,11 +328,20 @@ session_id=$(get_latest_session_id)
|
|
|
204
328
|
|
|
205
329
|
if [ -z "$session_id" ]; then
|
|
206
330
|
log "No session found, skipping memory extraction"
|
|
331
|
+
cleanup_timestamp
|
|
332
|
+
exit $opencode_exit
|
|
333
|
+
fi
|
|
334
|
+
|
|
335
|
+
# Step 3.5: Check if memories were already written during the session
|
|
336
|
+
if has_new_memories; then
|
|
337
|
+
log "Main agent already wrote memories during session, skipping extraction"
|
|
338
|
+
cleanup_timestamp
|
|
207
339
|
exit $opencode_exit
|
|
208
340
|
fi
|
|
209
341
|
|
|
210
342
|
# Step 4: Acquire lock (prevent concurrent extractions)
|
|
211
343
|
if ! acquire_lock; then
|
|
344
|
+
cleanup_timestamp
|
|
212
345
|
exit $opencode_exit
|
|
213
346
|
fi
|
|
214
347
|
|
|
@@ -216,11 +349,13 @@ fi
|
|
|
216
349
|
if [ "$FOREGROUND" = "1" ]; then
|
|
217
350
|
# Foreground mode (for debugging)
|
|
218
351
|
run_extraction "$session_id"
|
|
352
|
+
cleanup_timestamp
|
|
219
353
|
else
|
|
220
354
|
# Background mode (default) β user isn't blocked
|
|
221
355
|
run_extraction "$session_id" &
|
|
222
356
|
disown
|
|
223
357
|
log "Memory extraction started in background (PID $!)"
|
|
358
|
+
cleanup_timestamp
|
|
224
359
|
fi
|
|
225
360
|
|
|
226
361
|
exit $opencode_exit
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-claude-memory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Claude Code-compatible memory compatibility layer for OpenCode β zero config, local-first, no migration",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"opencode": "./bin/opencode"
|
|
8
|
+
"opencode-memory": "./bin/opencode-memory"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./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
|
+
}
|