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 CHANGED
@@ -1,69 +1,29 @@
1
1
  <div align="center">
2
2
 
3
- # 🧠 opencode-claude-memory
3
+ # 🧠 Claude Code-compatible memory for OpenCode
4
4
 
5
- **A 1:1 replica of [Claude Code's memory system](https://github.com/anthropics/claude-code) for OpenCode**
5
+ **Make OpenCode and Claude Code share the same memory β€” zero config, local-first, and no migration required.**
6
6
 
7
- *Ported from the original source β€” same paths, same format, same tools, same prompts. Zero drift.*
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
  [![npm version](https://img.shields.io/npm/v/opencode-claude-memory.svg?style=flat-square)](https://www.npmjs.com/package/opencode-claude-memory)
12
10
  [![npm downloads](https://img.shields.io/npm/dm/opencode-claude-memory.svg?style=flat-square)](https://www.npmjs.com/package/opencode-claude-memory)
13
11
  [![License](https://img.shields.io/npm/l/opencode-claude-memory.svg?style=flat-square)](https://github.com/kuitos/opencode-claude-memory/blob/main/LICENSE)
14
12
 
15
- [Features](#-features) β€’ [Quick Start](#-quick-start) β€’ [How It Works](#-how-it-works) β€’ [Configuration](#%EF%B8%8F-configuration) β€’ [Tools Reference](#-tools-reference)
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
- ## ✨ 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
19
+ ## ✨ At a glance
63
20
 
64
- </td>
65
- </tr>
66
- </table>
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
- - An `opencode` **wrapper** β€” auto-extracts memories after each session
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 # just use it as usual
54
+ opencode
93
55
  ```
94
56
 
95
- The AI agent can now use memory tools:
57
+ That’s it. Memory extraction runs in the background after each session.
96
58
 
97
- - **"Remember that I prefer terse responses"** β†’ saves a `feedback` memory
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 wrapper and the plugin. Your saved memories in `~/.claude/projects/` are **not** deleted.
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
- </details>
76
+ Most memory plugins introduce a new storage model or migration step.
113
77
 
114
- ## πŸ’‘ How It Works
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[Wrapper finds real binary]
119
- B --> C[Runs opencode normally]
120
- C --> D[You exit]
121
- D --> E[Get latest session ID]
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 wrapper is a drop-in replacement that:
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
- 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
111
+ ## πŸ‘₯ Who this is for
132
112
 
133
- ### What "1:1 Replica" Means
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
- Every core component is ported directly from [Claude Code's source](https://github.com/anthropics/claude-code):
118
+ ## ❓ FAQ
136
119
 
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 |
120
+ ### Is this a new memory system?
144
121
 
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
122
+ No. It is a compatibility layer that lets OpenCode use Claude Code-compatible memory layout and conventions.
149
123
 
150
- ## βš™οΈ Configuration
124
+ ### Do I need to migrate existing memory?
151
125
 
152
- ### Environment Variables
126
+ No migration required. If you already have Claude Code memory files, OpenCode can work with them directly.
153
127
 
154
- | Variable | Default | Description |
155
- |---|---|---|
156
- | `OPENCODE_MEMORY_EXTRACT` | `1` | Set to `0` to disable auto-extraction |
157
- | `OPENCODE_MEMORY_FOREGROUND` | `0` | Set to `1` to run extraction in foreground (debugging) |
158
- | `OPENCODE_MEMORY_MODEL` | *(default)* | Override model for extraction |
159
- | `OPENCODE_MEMORY_AGENT` | *(default)* | Override agent for extraction |
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 Safety
153
+ ### Concurrency safety
166
154
 
167
- A file lock prevents multiple extractions from running simultaneously on the same project. Stale locks (from crashed processes) are automatically cleaned up.
155
+ A file lock prevents multiple extractions from running simultaneously on the same project. Stale locks are cleaned up automatically.
168
156
 
169
- ## πŸ“ Memory Format
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
- ### Memory Types
187
-
188
- | Type | Description |
189
- |---|---|
190
- | `user` | User's role, expertise, preferences |
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
- ### `memory_read`
180
+ ## πŸ”§ Tools reference
234
181
 
235
- Read the full content of a specific memory file.
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 (memory wrapper) β€” Drop-in replacement that wraps the real 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
- # Install by placing this earlier in PATH than the real opencode binary.
7
- # The script finds the real opencode by scanning PATH and skipping itself.
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
- # Usage:
10
- # opencode [any opencode args...]
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. Finds the real `opencode` binary (skipping this wrapper in PATH)
14
- # 2. Runs it normally with all your arguments
15
- # 3. After you exit, finds the most recent session
16
- # 4. Forks that session and sends a memory extraction prompt
17
- # 5. The extraction runs in the background so you're not blocked
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 (after this wrapper)
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
- # Resolve the real opencode binary (skip this wrapper)
37
+ # Shell Hook Management
36
38
  # ============================================================================
37
39
 
38
- SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
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
- find_real_opencode() {
41
- local IFS=':'
42
- for dir in $PATH; do
43
- local candidate="$dir/opencode"
44
- if [ -x "$candidate" ] && [ "$(cd "$(dirname "$candidate")" && pwd -P)/$(basename "$candidate")" != "$SELF" ]; then
45
- echo "$candidate"
46
- return 0
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
- echo "[opencode-memory] ERROR: Cannot find real opencode binary in PATH" >&2
50
- echo "[opencode-memory] Make sure opencode is installed and this wrapper is placed earlier in PATH" >&2
51
- exit 1
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.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
- "description": "Cross-session memory plugin for OpenCode β€” Claude Code compatible, persistent, file-based memory",
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
- const memoryPrompt = buildMemorySystemPrompt(worktree)
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 { getMemoryDir, getMemoryEntrypoint, ENTRYPOINT_NAME } from "./paths.js"
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 endIndex = trimmed.indexOf("---", 3)
25
- if (endIndex === -1) {
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, fileName.endsWith(".md") ? fileName : `${fileName}.md`)
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, listMemories } from "./memory.js"
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
+ }