opencode-claude-memory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # opencode-memory
2
+
3
+ Cross-session memory plugin for [OpenCode](https://opencode.ai) — **fully compatible with Claude Code's memory format**.
4
+
5
+ Claude Code writes memories → OpenCode reads them.
6
+ OpenCode writes memories → Claude Code reads them.
7
+
8
+ ## Features
9
+
10
+ - **5 tools**: `memory_save`, `memory_delete`, `memory_list`, `memory_search`, `memory_read`
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)
15
+
16
+ ## Quick Start
17
+
18
+ ### 1. Install
19
+
20
+ ```bash
21
+ npm install -g opencode-claude-memory
22
+ ```
23
+
24
+ This does two things:
25
+
26
+ - Registers the **plugin** (memory tools + system prompt injection)
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.
30
+
31
+ ### 2. Configure the plugin
32
+
33
+ Add the plugin to your `opencode.json`:
34
+
35
+ ```jsonc
36
+ // opencode.json
37
+ {
38
+ "plugin": ["opencode-claude-memory"]
39
+ }
40
+ ```
41
+
42
+ ### 3. Use
43
+
44
+ Just run `opencode` as usual. The memory tools are available to the AI agent:
45
+
46
+ - **"Remember that I prefer terse responses"** → saves a `feedback` memory
47
+ - **"What do you remember about me?"** → reads from memory
48
+ - **"Forget the memory about my role"** → deletes a memory
49
+
50
+ When you exit a session, memories are automatically extracted in the background.
51
+
52
+ ### Uninstall
53
+
54
+ ```bash
55
+ npm uninstall -g opencode-claude-memory
56
+ ```
57
+
58
+ This removes both the wrapper and the plugin. Your saved memories in `~/.claude/projects/` are **not** deleted.
59
+
60
+ ## Auto-Extraction
61
+
62
+ The wrapper:
63
+
64
+ 1. Finds the real `opencode` binary (skips itself in `PATH`)
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
69
+
70
+ ### How it works
71
+
72
+ ```
73
+ You run `opencode`
74
+ → wrapper finds real opencode binary (skipping itself in PATH)
75
+ → runs real opencode with your arguments
76
+ → you exit
77
+ → opencode session list --format json -n 1 (get last session)
78
+ → opencode run -s <id> --fork "<extraction prompt>" (background)
79
+ → memories saved to ~/.claude/projects/<project>/memory/
80
+ ```
81
+
82
+ ### Environment variables
83
+
84
+ | Variable | Default | Description |
85
+ |---|---|---|
86
+ | `OPENCODE_MEMORY_EXTRACT` | `1` | Set to `0` to disable auto-extraction |
87
+ | `OPENCODE_MEMORY_FOREGROUND` | `0` | Set to `1` to run extraction in foreground (debugging) |
88
+ | `OPENCODE_MEMORY_MODEL` | *(default)* | Override model for extraction (e.g., `anthropic/claude-sonnet-4-20250514`) |
89
+ | `OPENCODE_MEMORY_AGENT` | *(default)* | Override agent for extraction |
90
+
91
+ ### Logs
92
+
93
+ Extraction logs are written to `$TMPDIR/opencode-memory-logs/extract-*.log`.
94
+
95
+ ### Concurrency safety
96
+
97
+ A file lock prevents multiple extractions from running simultaneously on the same project. Stale locks (from crashed processes) are automatically cleaned up.
98
+
99
+ ## Memory Format
100
+
101
+ Each memory is a Markdown file with YAML frontmatter:
102
+
103
+ ```markdown
104
+ ---
105
+ name: User prefers terse responses
106
+ description: User wants concise answers without trailing summaries
107
+ type: feedback
108
+ ---
109
+
110
+ Skip post-action summaries. User reads diffs directly.
111
+
112
+ **Why:** User explicitly requested terse output style.
113
+ **How to apply:** Don't summarize changes at the end of responses.
114
+ ```
115
+
116
+ ### Memory types
117
+
118
+ | Type | Description |
119
+ |---|---|
120
+ | `user` | User's role, expertise, preferences |
121
+ | `feedback` | Guidance on how to work (corrections and confirmations) |
122
+ | `project` | Ongoing work context not derivable from code |
123
+ | `reference` | Pointers to external resources |
124
+
125
+ ### Index file
126
+
127
+ `MEMORY.md` is an index (not content storage). Each entry is one line:
128
+
129
+ ```markdown
130
+ - [User prefers terse responses](feedback_terse_responses.md) — Skip summaries, user reads diffs
131
+ - [User is a data scientist](user_role.md) — Focus on observability/logging context
132
+ ```
133
+
134
+ ## Claude Code Compatibility
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
+ ```
161
+
162
+ ## Tools Reference
163
+
164
+ ### `memory_save`
165
+
166
+ Save or update a memory.
167
+
168
+ | Parameter | Type | Required | Description |
169
+ |---|---|---|---|
170
+ | `file_name` | string | yes | File name slug (e.g., `user_role`) |
171
+ | `name` | string | yes | Short title |
172
+ | `description` | string | yes | One-line description for relevance matching |
173
+ | `type` | enum | yes | `user`, `feedback`, `project`, or `reference` |
174
+ | `content` | string | yes | Memory content |
175
+
176
+ ### `memory_delete`
177
+
178
+ Delete a memory by file name.
179
+
180
+ ### `memory_list`
181
+
182
+ List all memories with their metadata.
183
+
184
+ ### `memory_search`
185
+
186
+ Search memories by keyword across name, description, and content.
187
+
188
+ ### `memory_read`
189
+
190
+ Read the full content of a specific memory file.
191
+
192
+ ## License
193
+
194
+ MIT
package/bin/opencode ADDED
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env bash
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.
5
+ #
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.
8
+ #
9
+ # Usage:
10
+ # opencode [any opencode args...]
11
+ #
12
+ # 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
18
+ #
19
+ # Requirements:
20
+ # - Real `opencode` CLI reachable in PATH (after this wrapper)
21
+ # - `jq` for JSON parsing
22
+ # - The opencode-memory plugin installed (provides memory_save tool)
23
+ #
24
+ # Environment variables:
25
+ # OPENCODE_MEMORY_EXTRACT=0 — Disable auto-extraction
26
+ # OPENCODE_MEMORY_FOREGROUND=1 — Run extraction in foreground (for debugging)
27
+ # OPENCODE_MEMORY_MODEL=... — Override model for extraction (e.g., "anthropic/claude-sonnet-4-20250514")
28
+ # OPENCODE_MEMORY_AGENT=... — Override agent for extraction
29
+ # OPENCODE_MEMORY_DIR=... — Override working directory for opencode
30
+ #
31
+
32
+ set -euo pipefail
33
+
34
+ # ============================================================================
35
+ # Resolve the real opencode binary (skip this wrapper)
36
+ # ============================================================================
37
+
38
+ SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
39
+
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
47
+ fi
48
+ 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
52
+ }
53
+
54
+ REAL_OPENCODE="$(find_real_opencode)"
55
+
56
+ # ============================================================================
57
+ # Configuration
58
+ # ============================================================================
59
+
60
+ EXTRACT_ENABLED="${OPENCODE_MEMORY_EXTRACT:-1}"
61
+ FOREGROUND="${OPENCODE_MEMORY_FOREGROUND:-0}"
62
+ EXTRACT_MODEL="${OPENCODE_MEMORY_MODEL:-}"
63
+ EXTRACT_AGENT="${OPENCODE_MEMORY_AGENT:-}"
64
+ WORKING_DIR="${OPENCODE_MEMORY_DIR:-$(pwd)}"
65
+
66
+ # Lock file to prevent concurrent extractions on the same project
67
+ LOCK_DIR="${TMPDIR:-/tmp}/opencode-memory-locks"
68
+ mkdir -p "$LOCK_DIR"
69
+ LOCK_FILE="$LOCK_DIR/$(echo "$WORKING_DIR" | sed 's/[^a-zA-Z0-9]/-/g').lock"
70
+
71
+ # Log file for background extraction
72
+ LOG_DIR="${TMPDIR:-/tmp}/opencode-memory-logs"
73
+ mkdir -p "$LOG_DIR"
74
+ LOG_FILE="$LOG_DIR/extract-$(date +%Y%m%d-%H%M%S).log"
75
+
76
+ # ============================================================================
77
+ # Extraction Prompt
78
+ # ============================================================================
79
+
80
+ # Adapted from Claude Code's extraction prompt, simplified for OpenCode's
81
+ # headless run mode. The model sees the full conversation context via --fork.
82
+ EXTRACT_PROMPT='You are now acting as the memory extraction subagent. Review the entire conversation above and extract any information worth remembering for future sessions.
83
+
84
+ ## What to save
85
+
86
+ Use the `memory_save` tool to persist memories. There are four types:
87
+
88
+ 1. **user** — Who the user is: role, expertise, preferences, communication style. Helps tailor future interactions.
89
+ 2. **feedback** — Guidance on how to work: corrections ("don'\''t do X"), confirmations ("yes, keep doing that"), approach preferences. Include *why* so edge cases can be judged.
90
+ 3. **project** — Ongoing work context: goals, deadlines, initiatives, decisions, bugs. NOT derivable from code/git. Convert relative dates to absolute.
91
+ 4. **reference** — Pointers to external resources: URLs, tool names, where to find information outside the codebase.
92
+
93
+ ## What NOT to save
94
+
95
+ - Code patterns, architecture, file structure — derivable from the codebase
96
+ - Git history, recent changes — use `git log`/`git blame`
97
+ - Debugging solutions — the fix is in the code
98
+ - Anything already in AGENTS.md / project config files
99
+ - Ephemeral task details or current conversation context
100
+ - Information that was already saved in a previous extraction
101
+
102
+ ## How to save
103
+
104
+ For each memory worth saving, call `memory_save` with:
105
+ - `file_name`: descriptive slug (e.g., `user_role`, `feedback_testing_approach`)
106
+ - `name`: short title
107
+ - `description`: one-line description (used for relevance matching in future sessions)
108
+ - `type`: one of user, feedback, project, reference
109
+ - `content`: the memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines.
110
+
111
+ ## Instructions
112
+
113
+ 1. Analyze the conversation for memorable information
114
+ 2. Check existing memories first (use `memory_list`) to avoid duplicates — update existing ones if needed
115
+ 3. Save each distinct memory as a separate entry
116
+ 4. If the conversation was trivial (e.g., just "hello" or a quick lookup), save nothing — that'\''s fine
117
+ 5. Be selective: 0-3 memories per session is typical. Quality over quantity.
118
+ 6. Do NOT save a memory about the extraction process itself.'
119
+
120
+ # ============================================================================
121
+ # Helper Functions
122
+ # ============================================================================
123
+
124
+ log() {
125
+ echo "[opencode-memory] $*" >&2
126
+ }
127
+
128
+ get_latest_session_id() {
129
+ local session_json
130
+ session_json=$("$REAL_OPENCODE" session list --format json -n 1 2>/dev/null) || return 1
131
+
132
+ # Parse with jq if available, fallback to grep
133
+ if command -v jq &>/dev/null; then
134
+ echo "$session_json" | jq -r '.[0].id // empty'
135
+ else
136
+ # Rough fallback: extract first "id" value
137
+ echo "$session_json" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/'
138
+ fi
139
+ }
140
+
141
+ acquire_lock() {
142
+ if [ -f "$LOCK_FILE" ]; then
143
+ local lock_pid
144
+ lock_pid=$(cat "$LOCK_FILE" 2>/dev/null || true)
145
+ if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then
146
+ log "Another extraction is already running (PID $lock_pid), skipping"
147
+ return 1
148
+ fi
149
+ # Stale lock — remove it
150
+ rm -f "$LOCK_FILE"
151
+ fi
152
+ echo $$ > "$LOCK_FILE"
153
+ return 0
154
+ }
155
+
156
+ release_lock() {
157
+ rm -f "$LOCK_FILE"
158
+ }
159
+
160
+ run_extraction() {
161
+ local session_id="$1"
162
+
163
+ log "Extracting memories from session $session_id..."
164
+ log "Log file: $LOG_FILE"
165
+
166
+ # Build the opencode run command
167
+ local cmd=("$REAL_OPENCODE" run -s "$session_id" --fork)
168
+
169
+ if [ -n "$EXTRACT_MODEL" ]; then
170
+ cmd+=(-m "$EXTRACT_MODEL")
171
+ fi
172
+
173
+ if [ -n "$EXTRACT_AGENT" ]; then
174
+ cmd+=(--agent "$EXTRACT_AGENT")
175
+ fi
176
+
177
+ cmd+=("$EXTRACT_PROMPT")
178
+
179
+ # Execute
180
+ if "${cmd[@]}" >> "$LOG_FILE" 2>&1; then
181
+ log "Memory extraction completed successfully"
182
+ else
183
+ log "Memory extraction failed (exit code $?). Check $LOG_FILE for details"
184
+ fi
185
+
186
+ release_lock
187
+ }
188
+
189
+ # ============================================================================
190
+ # Main
191
+ # ============================================================================
192
+
193
+ # Step 1: Run the real opencode with all original arguments, capture exit code
194
+ opencode_exit=0
195
+ "$REAL_OPENCODE" "$@" || opencode_exit=$?
196
+
197
+ # Step 2: Check if extraction is enabled
198
+ if [ "$EXTRACT_ENABLED" = "0" ]; then
199
+ exit $opencode_exit
200
+ fi
201
+
202
+ # Step 3: Get the most recent session ID
203
+ session_id=$(get_latest_session_id)
204
+
205
+ if [ -z "$session_id" ]; then
206
+ log "No session found, skipping memory extraction"
207
+ exit $opencode_exit
208
+ fi
209
+
210
+ # Step 4: Acquire lock (prevent concurrent extractions)
211
+ if ! acquire_lock; then
212
+ exit $opencode_exit
213
+ fi
214
+
215
+ # Step 5: Run extraction
216
+ if [ "$FOREGROUND" = "1" ]; then
217
+ # Foreground mode (for debugging)
218
+ run_extraction "$session_id"
219
+ else
220
+ # Background mode (default) — user isn't blocked
221
+ run_extraction "$session_id" &
222
+ disown
223
+ log "Memory extraction started in background (PID $!)"
224
+ fi
225
+
226
+ exit $opencode_exit
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "opencode-claude-memory",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Cross-session memory plugin for OpenCode — Claude Code compatible, persistent, file-based memory",
6
+ "main": "src/index.ts",
7
+ "bin": {
8
+ "opencode": "./bin/opencode"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "bin"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kuitos/opencode-claude-memory.git"
20
+ },
21
+ "homepage": "https://github.com/kuitos/opencode-claude-memory#readme",
22
+ "keywords": [
23
+ "opencode",
24
+ "plugin",
25
+ "memory",
26
+ "persistent",
27
+ "cross-session",
28
+ "claude-code-compatible"
29
+ ],
30
+ "license": "MIT",
31
+ "peerDependencies": {
32
+ "@opencode-ai/plugin": "*"
33
+ },
34
+ "dependencies": {
35
+ "zod": "^3.24.0"
36
+ }
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { tool } from "@opencode-ai/plugin"
3
+ import { buildMemorySystemPrompt } from "./prompt.js"
4
+ import {
5
+ saveMemory,
6
+ deleteMemory,
7
+ listMemories,
8
+ searchMemories,
9
+ readMemory,
10
+ readIndex,
11
+ MEMORY_TYPES,
12
+ type MemoryType,
13
+ } from "./memory.js"
14
+ import { getMemoryDir } from "./paths.js"
15
+
16
+ export const MemoryPlugin: Plugin = async ({ worktree }) => {
17
+ getMemoryDir(worktree)
18
+
19
+ return {
20
+ "experimental.chat.system.transform": async (_input, output) => {
21
+ const memoryPrompt = buildMemorySystemPrompt(worktree)
22
+ output.system.push(memoryPrompt)
23
+ },
24
+
25
+ tool: {
26
+ memory_save: tool({
27
+ description:
28
+ "Save or update a memory for future conversations. " +
29
+ "Each memory is stored as a markdown file with frontmatter. " +
30
+ "Use this when the user explicitly asks you to remember something, " +
31
+ "or when you observe important information worth preserving across sessions " +
32
+ "(user preferences, feedback, project context, external references). " +
33
+ "Check existing memories first with memory_list or memory_search to avoid duplicates.",
34
+ args: {
35
+ file_name: tool.schema
36
+ .string()
37
+ .describe(
38
+ 'File name for the memory (without .md extension). Use snake_case, e.g. "user_role", "feedback_testing_style", "project_auth_rewrite"',
39
+ ),
40
+ name: tool.schema.string().describe("Human-readable name for this memory"),
41
+ description: tool.schema
42
+ .string()
43
+ .describe("One-line description — used to decide relevance in future conversations, so be specific"),
44
+ type: tool.schema
45
+ .enum(MEMORY_TYPES)
46
+ .describe(
47
+ "Memory type: user (about the person), feedback (guidance on approach), project (ongoing work context), reference (pointers to external systems)",
48
+ ),
49
+ content: tool.schema
50
+ .string()
51
+ .describe(
52
+ "Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines",
53
+ ),
54
+ },
55
+ async execute(args) {
56
+ const filePath = saveMemory(worktree, args.file_name, args.name, args.description, args.type, args.content)
57
+ return `Memory saved to ${filePath}`
58
+ },
59
+ }),
60
+
61
+ memory_delete: tool({
62
+ description: "Delete a memory that is outdated, wrong, or no longer relevant. Also removes it from the index.",
63
+ args: {
64
+ file_name: tool.schema.string().describe("File name of the memory to delete (with or without .md extension)"),
65
+ },
66
+ async execute(args) {
67
+ const deleted = deleteMemory(worktree, args.file_name)
68
+ return deleted ? `Memory "${args.file_name}" deleted.` : `Memory "${args.file_name}" not found.`
69
+ },
70
+ }),
71
+
72
+ memory_list: tool({
73
+ description:
74
+ "List all saved memories with their names, types, and descriptions. " +
75
+ "Use this to check what memories exist before saving a new one (to avoid duplicates) " +
76
+ "or when you need to recall what's been stored.",
77
+ args: {},
78
+ async execute() {
79
+ const entries = listMemories(worktree)
80
+ if (entries.length === 0) {
81
+ return "No memories saved yet."
82
+ }
83
+ const lines = entries.map(
84
+ (e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}`,
85
+ )
86
+ return `${entries.length} memories found:\n${lines.join("\n")}`
87
+ },
88
+ }),
89
+
90
+ memory_search: tool({
91
+ description:
92
+ "Search memories by keyword. Searches across names, descriptions, and content. " +
93
+ "Use this to find relevant memories before answering questions or when the user references past conversations.",
94
+ args: {
95
+ query: tool.schema.string().describe("Search query — searches across name, description, and content"),
96
+ },
97
+ async execute(args) {
98
+ const results = searchMemories(worktree, args.query)
99
+ if (results.length === 0) {
100
+ return `No memories matching "${args.query}".`
101
+ }
102
+ const lines = results.map(
103
+ (e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}\n Content: ${e.content.slice(0, 200)}${e.content.length > 200 ? "..." : ""}`,
104
+ )
105
+ return `${results.length} matches for "${args.query}":\n${lines.join("\n")}`
106
+ },
107
+ }),
108
+
109
+ memory_read: tool({
110
+ description: "Read the full content of a specific memory file.",
111
+ args: {
112
+ file_name: tool.schema.string().describe("File name of the memory to read (with or without .md extension)"),
113
+ },
114
+ async execute(args) {
115
+ const entry = readMemory(worktree, args.file_name)
116
+ if (!entry) {
117
+ return `Memory "${args.file_name}" not found.`
118
+ }
119
+ return `# ${entry.name}\n**Type:** ${entry.type}\n**Description:** ${entry.description}\n\n${entry.content}`
120
+ },
121
+ }),
122
+ },
123
+ }
124
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,219 @@
1
+ import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "fs"
2
+ import { join, basename } from "path"
3
+ import { getMemoryDir, getMemoryEntrypoint, ENTRYPOINT_NAME } from "./paths.js"
4
+
5
+ export const MEMORY_TYPES = ["user", "feedback", "project", "reference"] as const
6
+ export type MemoryType = (typeof MEMORY_TYPES)[number]
7
+
8
+ export type MemoryEntry = {
9
+ filePath: string
10
+ fileName: string
11
+ name: string
12
+ description: string
13
+ type: MemoryType
14
+ content: string
15
+ rawContent: string
16
+ }
17
+
18
+ function parseFrontmatter(raw: string): { frontmatter: Record<string, string>; content: string } {
19
+ const trimmed = raw.trim()
20
+ if (!trimmed.startsWith("---")) {
21
+ return { frontmatter: {}, content: trimmed }
22
+ }
23
+
24
+ const endIndex = trimmed.indexOf("---", 3)
25
+ if (endIndex === -1) {
26
+ return { frontmatter: {}, content: trimmed }
27
+ }
28
+
29
+ const frontmatterBlock = trimmed.slice(3, endIndex).trim()
30
+ const content = trimmed.slice(endIndex + 3).trim()
31
+
32
+ const frontmatter: Record<string, string> = {}
33
+ for (const line of frontmatterBlock.split("\n")) {
34
+ const colonIdx = line.indexOf(":")
35
+ if (colonIdx === -1) continue
36
+ const key = line.slice(0, colonIdx).trim()
37
+ const value = line.slice(colonIdx + 1).trim()
38
+ if (key && value) {
39
+ frontmatter[key] = value
40
+ }
41
+ }
42
+
43
+ return { frontmatter, content }
44
+ }
45
+
46
+ function buildFrontmatter(name: string, description: string, type: MemoryType): string {
47
+ return `---\nname: ${name}\ndescription: ${description}\ntype: ${type}\n---`
48
+ }
49
+
50
+ function parseMemoryType(raw: string | undefined): MemoryType | undefined {
51
+ if (!raw) return undefined
52
+ return MEMORY_TYPES.find((t) => t === raw)
53
+ }
54
+
55
+ export function listMemories(worktree: string): MemoryEntry[] {
56
+ const memDir = getMemoryDir(worktree)
57
+ const entries: MemoryEntry[] = []
58
+
59
+ let files: string[]
60
+ try {
61
+ files = readdirSync(memDir)
62
+ .filter((f) => f.endsWith(".md") && f !== ENTRYPOINT_NAME)
63
+ .sort()
64
+ } catch {
65
+ return entries
66
+ }
67
+
68
+ for (const fileName of files) {
69
+ const filePath = join(memDir, fileName)
70
+ try {
71
+ const rawContent = readFileSync(filePath, "utf-8")
72
+ const { frontmatter, content } = parseFrontmatter(rawContent)
73
+ entries.push({
74
+ filePath,
75
+ fileName,
76
+ name: frontmatter.name ?? fileName.replace(/\.md$/, ""),
77
+ description: frontmatter.description ?? "",
78
+ type: parseMemoryType(frontmatter.type) ?? "user",
79
+ content,
80
+ rawContent,
81
+ })
82
+ } catch {
83
+
84
+ }
85
+ }
86
+
87
+ return entries
88
+ }
89
+
90
+ export function readMemory(worktree: string, fileName: string): MemoryEntry | null {
91
+ const memDir = getMemoryDir(worktree)
92
+ const filePath = join(memDir, fileName.endsWith(".md") ? fileName : `${fileName}.md`)
93
+
94
+ try {
95
+ const rawContent = readFileSync(filePath, "utf-8")
96
+ const { frontmatter, content } = parseFrontmatter(rawContent)
97
+ return {
98
+ filePath,
99
+ fileName: basename(filePath),
100
+ name: frontmatter.name ?? fileName.replace(/\.md$/, ""),
101
+ description: frontmatter.description ?? "",
102
+ type: parseMemoryType(frontmatter.type) ?? "user",
103
+ content,
104
+ rawContent,
105
+ }
106
+ } catch {
107
+ return null
108
+ }
109
+ }
110
+
111
+ export function saveMemory(
112
+ worktree: string,
113
+ fileName: string,
114
+ name: string,
115
+ description: string,
116
+ type: MemoryType,
117
+ content: string,
118
+ ): string {
119
+ const memDir = getMemoryDir(worktree)
120
+ const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
121
+ const filePath = join(memDir, safeName)
122
+
123
+ const fileContent = `${buildFrontmatter(name, description, type)}\n\n${content.trim()}\n`
124
+ writeFileSync(filePath, fileContent, "utf-8")
125
+
126
+ updateIndex(worktree, safeName, name, description)
127
+
128
+ return filePath
129
+ }
130
+
131
+ export function deleteMemory(worktree: string, fileName: string): boolean {
132
+ const memDir = getMemoryDir(worktree)
133
+ const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
134
+ const filePath = join(memDir, safeName)
135
+
136
+ try {
137
+ unlinkSync(filePath)
138
+ removeFromIndex(worktree, safeName)
139
+ return true
140
+ } catch {
141
+ return false
142
+ }
143
+ }
144
+
145
+ export function searchMemories(worktree: string, query: string): MemoryEntry[] {
146
+ const all = listMemories(worktree)
147
+ const lowerQuery = query.toLowerCase()
148
+
149
+ return all.filter(
150
+ (entry) =>
151
+ entry.name.toLowerCase().includes(lowerQuery) ||
152
+ entry.description.toLowerCase().includes(lowerQuery) ||
153
+ entry.content.toLowerCase().includes(lowerQuery),
154
+ )
155
+ }
156
+
157
+ export function readIndex(worktree: string): string {
158
+ const entrypoint = getMemoryEntrypoint(worktree)
159
+ try {
160
+ return readFileSync(entrypoint, "utf-8")
161
+ } catch {
162
+ return ""
163
+ }
164
+ }
165
+
166
+ function updateIndex(worktree: string, fileName: string, name: string, description: string): void {
167
+ const entrypoint = getMemoryEntrypoint(worktree)
168
+ const existing = readIndex(worktree)
169
+ const lines = existing.split("\n").filter((l) => l.trim())
170
+
171
+ const pointer = `- [${name}](${fileName}) — ${description}`
172
+ const existingIdx = lines.findIndex((l) => l.includes(`(${fileName})`))
173
+
174
+ if (existingIdx >= 0) {
175
+ lines[existingIdx] = pointer
176
+ } else {
177
+ lines.push(pointer)
178
+ }
179
+
180
+ writeFileSync(entrypoint, lines.join("\n") + "\n", "utf-8")
181
+ }
182
+
183
+ function removeFromIndex(worktree: string, fileName: string): void {
184
+ const entrypoint = getMemoryEntrypoint(worktree)
185
+ const existing = readIndex(worktree)
186
+ const lines = existing
187
+ .split("\n")
188
+ .filter((l) => l.trim() && !l.includes(`(${fileName})`))
189
+
190
+ writeFileSync(entrypoint, lines.length > 0 ? lines.join("\n") + "\n" : "", "utf-8")
191
+ }
192
+
193
+ export function truncateEntrypoint(raw: string): { content: string; wasTruncated: boolean } {
194
+ const trimmed = raw.trim()
195
+ if (!trimmed) return { content: "", wasTruncated: false }
196
+
197
+ const lines = trimmed.split("\n")
198
+ const lineCount = lines.length
199
+ const byteCount = trimmed.length
200
+
201
+ const wasLineTruncated = lineCount > 200
202
+ const wasByteTruncated = byteCount > 25_000
203
+
204
+ if (!wasLineTruncated && !wasByteTruncated) {
205
+ return { content: trimmed, wasTruncated: false }
206
+ }
207
+
208
+ let truncated = wasLineTruncated ? lines.slice(0, 200).join("\n") : trimmed
209
+
210
+ if (truncated.length > 25_000) {
211
+ const cutAt = truncated.lastIndexOf("\n", 25_000)
212
+ truncated = truncated.slice(0, cutAt > 0 ? cutAt : 25_000)
213
+ }
214
+
215
+ return {
216
+ content: truncated + "\n\n> WARNING: MEMORY.md was truncated. Keep index entries concise.",
217
+ wasTruncated: true,
218
+ }
219
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,136 @@
1
+ // Claude Code compatible memory directory path resolution.
2
+ // Directory: ~/.claude/projects/<sanitizePath(canonicalGitRoot)>/memory/
3
+ // Ensures bidirectional memory sharing between Claude Code and OpenCode.
4
+
5
+ import { homedir } from "os"
6
+ import { join, dirname, resolve, sep } from "path"
7
+ import { mkdirSync, existsSync, readFileSync, statSync, realpathSync } from "fs"
8
+
9
+ export const ENTRYPOINT_NAME = "MEMORY.md"
10
+ export const MAX_ENTRYPOINT_LINES = 200
11
+ export const MAX_ENTRYPOINT_BYTES = 25_000
12
+
13
+ const MAX_SANITIZED_LENGTH = 200
14
+
15
+ // Exact copy of Claude Code's djb2Hash() from utils/hash.ts
16
+ function djb2Hash(str: string): number {
17
+ let hash = 0
18
+ for (let i = 0; i < str.length; i++) {
19
+ hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
20
+ }
21
+ return hash
22
+ }
23
+
24
+ function simpleHash(str: string): string {
25
+ return Math.abs(djb2Hash(str)).toString(36)
26
+ }
27
+
28
+ // Exact copy of Claude Code's sanitizePath() from utils/sessionStoragePortable.ts
29
+ export function sanitizePath(name: string): string {
30
+ const sanitized = name.replace(/[^a-zA-Z0-9]/g, "-")
31
+ if (sanitized.length <= MAX_SANITIZED_LENGTH) {
32
+ return sanitized
33
+ }
34
+ const hash = simpleHash(name)
35
+ return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${hash}`
36
+ }
37
+
38
+ // Matches Claude Code's findGitRoot() from utils/git.ts
39
+ function findGitRoot(startPath: string): string | null {
40
+ let current = resolve(startPath)
41
+ const root = current.substring(0, current.indexOf(sep) + 1) || sep
42
+
43
+ while (current !== root) {
44
+ try {
45
+ const gitPath = join(current, ".git")
46
+ const s = statSync(gitPath)
47
+ if (s.isDirectory() || s.isFile()) {
48
+ return current.normalize("NFC")
49
+ }
50
+ } catch {}
51
+ const parent = dirname(current)
52
+ if (parent === current) break
53
+ current = parent
54
+ }
55
+
56
+ try {
57
+ const gitPath = join(root, ".git")
58
+ const s = statSync(gitPath)
59
+ if (s.isDirectory() || s.isFile()) {
60
+ return root.normalize("NFC")
61
+ }
62
+ } catch {}
63
+
64
+ return null
65
+ }
66
+
67
+ // Matches Claude Code's resolveCanonicalRoot() from utils/git.ts
68
+ // Resolves worktrees to the main repo root via .git -> gitdir -> commondir chain
69
+ function resolveCanonicalRoot(gitRoot: string): string {
70
+ try {
71
+ const gitContent = readFileSync(join(gitRoot, ".git"), "utf-8").trim()
72
+ if (!gitContent.startsWith("gitdir:")) {
73
+ return gitRoot
74
+ }
75
+ const worktreeGitDir = resolve(gitRoot, gitContent.slice("gitdir:".length).trim())
76
+
77
+ const commonDir = resolve(
78
+ worktreeGitDir,
79
+ readFileSync(join(worktreeGitDir, "commondir"), "utf-8").trim(),
80
+ )
81
+
82
+ // SECURITY: validate worktreeGitDir is a direct child of <commonDir>/worktrees/
83
+ if (resolve(dirname(worktreeGitDir)) !== join(commonDir, "worktrees")) {
84
+ return gitRoot
85
+ }
86
+
87
+ // SECURITY: validate gitdir back-link points to our .git
88
+ const backlink = realpathSync(
89
+ readFileSync(join(worktreeGitDir, "gitdir"), "utf-8").trim(),
90
+ )
91
+ if (backlink !== join(realpathSync(gitRoot), ".git")) {
92
+ return gitRoot
93
+ }
94
+
95
+ if (commonDir.endsWith(`${sep}.git`) || commonDir.endsWith("/.git")) {
96
+ return dirname(commonDir).normalize("NFC")
97
+ }
98
+
99
+ return commonDir.normalize("NFC")
100
+ } catch {
101
+ return gitRoot
102
+ }
103
+ }
104
+
105
+ export function findCanonicalGitRoot(startPath: string): string | null {
106
+ const root = findGitRoot(startPath)
107
+ if (!root) return null
108
+ return resolveCanonicalRoot(root)
109
+ }
110
+
111
+ function getClaudeConfigHomeDir(): string {
112
+ return (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude")).normalize("NFC")
113
+ }
114
+
115
+ export function getMemoryDir(worktree: string): string {
116
+ const canonicalRoot = findCanonicalGitRoot(worktree) ?? worktree
117
+ const projectsDir = join(getClaudeConfigHomeDir(), "projects")
118
+ const memoryDir = join(projectsDir, sanitizePath(canonicalRoot), "memory")
119
+ ensureDir(memoryDir)
120
+ return memoryDir
121
+ }
122
+
123
+ export function getMemoryEntrypoint(worktree: string): string {
124
+ return join(getMemoryDir(worktree), ENTRYPOINT_NAME)
125
+ }
126
+
127
+ export function isMemoryPath(absolutePath: string, worktree: string): boolean {
128
+ const memDir = getMemoryDir(worktree)
129
+ return absolutePath.startsWith(memDir)
130
+ }
131
+
132
+ export function ensureDir(dir: string): void {
133
+ if (!existsSync(dir)) {
134
+ mkdirSync(dir, { recursive: true })
135
+ }
136
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { MEMORY_TYPES } from "./memory.js"
2
+ import { readIndex, truncateEntrypoint, listMemories } from "./memory.js"
3
+ import { getMemoryDir, ENTRYPOINT_NAME } from "./paths.js"
4
+
5
+ const FRONTMATTER_EXAMPLE = `\`\`\`markdown
6
+ ---
7
+ name: {{memory name}}
8
+ description: {{one-line description — used to decide relevance in future conversations, so be specific}}
9
+ type: {{${MEMORY_TYPES.join(", ")}}}
10
+ ---
11
+
12
+ {{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}
13
+ \`\`\``
14
+
15
+ const TYPES_SECTION = `## Types of memory
16
+
17
+ There are several discrete types of memory that you can store:
18
+
19
+ <types>
20
+ <type>
21
+ <name>user</name>
22
+ <description>Information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective.</description>
23
+ <when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>
24
+ <how_to_use>When your work should be informed by the user's profile or perspective.</how_to_use>
25
+ <examples>
26
+ user: I'm a data scientist investigating what logging we have in place
27
+ assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]
28
+ </examples>
29
+ </type>
30
+ <type>
31
+ <name>feedback</name>
32
+ <description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. Record from failure AND success.</description>
33
+ <when_to_save>Any time the user corrects your approach ("no not that", "don't", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that"). Include *why* so you can judge edge cases later.</when_to_save>
34
+ <how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>
35
+ <body_structure>Lead with the rule itself, then a **Why:** line and a **How to apply:** line.</body_structure>
36
+ <examples>
37
+ user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed
38
+ assistant: [saves feedback memory: integration tests must hit a real database, not mocks]
39
+ </examples>
40
+ </type>
41
+ <type>
42
+ <name>project</name>
43
+ <description>Information about ongoing work, goals, initiatives, bugs, or incidents that is not derivable from the code or git history.</description>
44
+ <when_to_save>When you learn who is doing what, why, or by when. Always convert relative dates to absolute dates when saving.</when_to_save>
45
+ <how_to_use>Use these memories to understand the broader context behind the user's request.</how_to_use>
46
+ <body_structure>Lead with the fact or decision, then a **Why:** line and a **How to apply:** line.</body_structure>
47
+ <examples>
48
+ user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch
49
+ assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut]
50
+ </examples>
51
+ </type>
52
+ <type>
53
+ <name>reference</name>
54
+ <description>Pointers to where information can be found in external systems.</description>
55
+ <when_to_save>When you learn about resources in external systems and their purpose.</when_to_save>
56
+ <how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>
57
+ <examples>
58
+ user: check the Linear project "INGEST" if you want context on these tickets
59
+ assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]
60
+ </examples>
61
+ </type>
62
+ </types>`
63
+
64
+ const WHAT_NOT_TO_SAVE = `## What NOT to save in memory
65
+
66
+ - Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
67
+ - Git history, recent changes, or who-changed-what — \`git log\` / \`git blame\` are authoritative.
68
+ - Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
69
+ - Anything already documented in AGENTS.md or project rules files.
70
+ - Ephemeral task details: in-progress work, temporary state, current conversation context.
71
+
72
+ These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.`
73
+
74
+ const WHEN_TO_ACCESS = `## When to access memories
75
+ - When memories seem relevant, or the user references prior-conversation work.
76
+ - You MUST access memory when the user explicitly asks you to check, recall, or remember.
77
+ - If the user says to *ignore* or *not use* memory: proceed as if MEMORY.md were empty.
78
+ - Memory records can become stale over time. Before answering based solely on memory, verify against current state. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory.`
79
+
80
+ const TRUSTING_RECALL = `## Before recommending from memory
81
+
82
+ A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:
83
+
84
+ - If the memory names a file path: check the file exists.
85
+ - If the memory names a function or flag: grep for it.
86
+ - If the user is about to act on your recommendation, verify first.
87
+
88
+ "The memory says X exists" is not the same as "X exists now."
89
+
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
+
92
+ export function buildMemorySystemPrompt(worktree: string): string {
93
+ const memoryDir = getMemoryDir(worktree)
94
+ const indexContent = readIndex(worktree)
95
+
96
+ const lines: string[] = [
97
+ "# Auto Memory",
98
+ "",
99
+ `You have a persistent, file-based memory system at \`${memoryDir}\`. This directory already exists — write to it directly (do not run mkdir or check for its existence).`,
100
+ "",
101
+ "You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.",
102
+ "",
103
+ "If the user explicitly asks you to remember something, save it immediately using the `memory_save` tool as whichever type fits best. If they ask you to forget something, find and remove the relevant entry using `memory_delete`.",
104
+ "",
105
+ TYPES_SECTION,
106
+ "",
107
+ WHAT_NOT_TO_SAVE,
108
+ "",
109
+ `## How to save memories`,
110
+ "",
111
+ "Use the `memory_save` tool to create or update a memory. Each memory goes in its own file with frontmatter:",
112
+ "",
113
+ FRONTMATTER_EXAMPLE,
114
+ "",
115
+ `- The \`${ENTRYPOINT_NAME}\` index is managed automatically — you don't need to edit it`,
116
+ "- Organize memory semantically by topic, not chronologically",
117
+ "- Update or remove memories that turn out to be wrong or outdated",
118
+ "- Do not write duplicate memories. First use `memory_list` or `memory_search` to check if there is an existing memory you can update before writing a new one.",
119
+ "",
120
+ WHEN_TO_ACCESS,
121
+ "",
122
+ TRUSTING_RECALL,
123
+ "",
124
+ "## Memory and other forms of persistence",
125
+ "Memory is one of several persistence mechanisms. The distinction is that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.",
126
+ "- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task, use a Plan rather than saving this to memory.",
127
+ "- When to use or update tasks instead of memory: When you need to break your work into discrete steps or track progress, use tasks instead of saving to memory.",
128
+ "",
129
+ ]
130
+
131
+ if (indexContent.trim()) {
132
+ const { content: truncated } = truncateEntrypoint(indexContent)
133
+ lines.push(`## ${ENTRYPOINT_NAME}`, "", truncated)
134
+ } else {
135
+ lines.push(
136
+ `## ${ENTRYPOINT_NAME}`,
137
+ "",
138
+ `Your ${ENTRYPOINT_NAME} is currently empty. When you save new memories, they will appear here.`,
139
+ )
140
+ }
141
+
142
+ return lines.join("\n")
143
+ }