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 +194 -0
- package/bin/opencode +226 -0
- package/package.json +37 -0
- package/src/index.ts +124 -0
- package/src/memory.ts +219 -0
- package/src/paths.ts +136 -0
- package/src/prompt.ts +143 -0
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
|
+
}
|