opencode-claude-memory 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 kuitos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,19 +1,71 @@
1
- # opencode-memory
1
+ <div align="center">
2
2
 
3
- Cross-session memory plugin for [OpenCode](https://opencode.ai) — **fully compatible with Claude Code's memory format**.
3
+ # 🧠 opencode-claude-memory
4
4
 
5
- Claude Code writes memories OpenCode reads them.
6
- OpenCode writes memories → Claude Code reads them.
5
+ **A 1:1 replica of [Claude Code's memory system](https://github.com/anthropics/claude-code) for OpenCode**
7
6
 
8
- ## Features
7
+ *Ported from the original source — same paths, same format, same tools, same prompts. Zero drift.*
9
8
 
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)
9
+ Claude Code writes memories OpenCode reads them. OpenCode writes memories → Claude Code reads them.
15
10
 
16
- ## Quick Start
11
+ [![npm version](https://img.shields.io/npm/v/opencode-claude-memory.svg?style=flat-square)](https://www.npmjs.com/package/opencode-claude-memory)
12
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-claude-memory.svg?style=flat-square)](https://www.npmjs.com/package/opencode-claude-memory)
13
+ [![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
+
15
+ [Features](#-features) • [Quick Start](#-quick-start) • [How It Works](#-how-it-works) • [Configuration](#%EF%B8%8F-configuration) • [Tools Reference](#-tools-reference)
16
+
17
+ </div>
18
+
19
+ ---
20
+
21
+ ## ✨ Features
22
+
23
+ <table>
24
+ <tr>
25
+ <td width="50%">
26
+
27
+ ### 🔄 Claude Code Compatible
28
+ Shares the exact same `~/.claude/projects/<project>/memory/` directory — bidirectional sync out of the box
29
+
30
+ </td>
31
+ <td width="50%">
32
+
33
+ ### 🛠️ 5 Memory Tools
34
+ `memory_save`, `memory_delete`, `memory_list`, `memory_search`, `memory_read`
35
+
36
+ </td>
37
+ </tr>
38
+ <tr>
39
+ <td width="50%">
40
+
41
+ ### ⚡ Auto-Extraction
42
+ Drop-in `opencode` wrapper that extracts memories in the background after each session
43
+
44
+ </td>
45
+ <td width="50%">
46
+
47
+ ### 💉 System Prompt Injection
48
+ Existing memories are automatically injected into every conversation
49
+
50
+ </td>
51
+ </tr>
52
+ <tr>
53
+ <td width="50%">
54
+
55
+ ### 📁 4 Memory Types
56
+ `user`, `feedback`, `project`, `reference` — same taxonomy as Claude Code
57
+
58
+ </td>
59
+ <td width="50%">
60
+
61
+ ### 🌳 Git Worktree Aware
62
+ Worktrees of the same repo share the same memory directory
63
+
64
+ </td>
65
+ </tr>
66
+ </table>
67
+
68
+ ## 🚀 Quick Start
17
69
 
18
70
  ### 1. Install
19
71
 
@@ -21,16 +73,11 @@ OpenCode writes memories → Claude Code reads them.
21
73
  npm install -g opencode-claude-memory
22
74
  ```
23
75
 
24
- This 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.
76
+ This installs:
77
+ - The **plugin** — memory tools + system prompt injection
78
+ - An `opencode` **wrapper** auto-extracts memories after each session
30
79
 
31
- ### 2. Configure the plugin
32
-
33
- Add the plugin to your `opencode.json`:
80
+ ### 2. Configure
34
81
 
35
82
  ```jsonc
36
83
  // opencode.json
@@ -41,62 +88,85 @@ Add the plugin to your `opencode.json`:
41
88
 
42
89
  ### 3. Use
43
90
 
44
- Just run `opencode` as usual. The memory tools are available to the AI agent:
91
+ ```bash
92
+ opencode # just use it as usual
93
+ ```
94
+
95
+ The AI agent can now use memory tools:
45
96
 
46
97
  - **"Remember that I prefer terse responses"** → saves a `feedback` memory
47
98
  - **"What do you remember about me?"** → reads from memory
48
99
  - **"Forget the memory about my role"** → deletes a memory
49
100
 
50
- When you exit a session, memories are automatically extracted in the background.
101
+ When you exit, memories are extracted in the background — zero blocking.
51
102
 
52
- ### Uninstall
103
+ <details>
104
+ <summary>🗑️ Uninstall</summary>
53
105
 
54
106
  ```bash
55
107
  npm uninstall -g opencode-claude-memory
56
108
  ```
57
109
 
58
- This removes both the wrapper and the plugin. Your saved memories in `~/.claude/projects/` are **not** deleted.
110
+ This removes the wrapper and the plugin. Your saved memories in `~/.claude/projects/` are **not** deleted.
111
+
112
+ </details>
59
113
 
60
- ## Auto-Extraction
114
+ ## 💡 How It Works
61
115
 
62
- The wrapper:
116
+ ```mermaid
117
+ graph LR
118
+ A[You run opencode] --> B[Wrapper finds real binary]
119
+ B --> C[Runs opencode normally]
120
+ C --> D[You exit]
121
+ D --> E[Get latest session ID]
122
+ E --> F[Fork session + extract memories]
123
+ F --> G[Memories saved to ~/.claude/projects/]
124
+ ```
63
125
 
64
- 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
126
+ The wrapper is a drop-in replacement that:
69
127
 
70
- ### How it works
128
+ 1. Scans `PATH` to find the real `opencode` binary (skipping itself)
129
+ 2. Runs it with all your arguments
130
+ 3. After you exit, forks the session with a memory extraction prompt
131
+ 4. Extraction runs **in the background** — you're never blocked
71
132
 
72
- ```
73
- 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
- ```
133
+ ### What "1:1 Replica" Means
134
+
135
+ Every core component is ported directly from [Claude Code's source](https://github.com/anthropics/claude-code):
136
+
137
+ | Component | Source |
138
+ |---|---|
139
+ | `sanitizePath()` + `djb2Hash()` | `utils/sessionStoragePortable.ts` |
140
+ | `findGitRoot()` + worktree resolution | `utils/git.ts` |
141
+ | Memory types & frontmatter format | `commands/memory.ts` |
142
+ | System prompt (types, when to save/skip) | `commands/memory.ts` |
143
+ | Extraction prompt (post-session) | Claude Code's memory extraction agent |
144
+
145
+ This ensures:
146
+ - `~/.claude/projects/<sanitized>/memory/` paths are **byte-identical** to Claude Code's output
147
+ - Git worktrees resolve to the same canonical root
148
+ - Memory files are interchangeable — no migration needed
149
+
150
+ ## ⚙️ Configuration
81
151
 
82
- ### Environment variables
152
+ ### Environment Variables
83
153
 
84
154
  | Variable | Default | Description |
85
155
  |---|---|---|
86
156
  | `OPENCODE_MEMORY_EXTRACT` | `1` | Set to `0` to disable auto-extraction |
87
157
  | `OPENCODE_MEMORY_FOREGROUND` | `0` | Set to `1` to run extraction in foreground (debugging) |
88
- | `OPENCODE_MEMORY_MODEL` | *(default)* | Override model for extraction (e.g., `anthropic/claude-sonnet-4-20250514`) |
158
+ | `OPENCODE_MEMORY_MODEL` | *(default)* | Override model for extraction |
89
159
  | `OPENCODE_MEMORY_AGENT` | *(default)* | Override agent for extraction |
90
160
 
91
161
  ### Logs
92
162
 
93
163
  Extraction logs are written to `$TMPDIR/opencode-memory-logs/extract-*.log`.
94
164
 
95
- ### Concurrency safety
165
+ ### Concurrency Safety
96
166
 
97
167
  A file lock prevents multiple extractions from running simultaneously on the same project. Stale locks (from crashed processes) are automatically cleaned up.
98
168
 
99
- ## Memory Format
169
+ ## 📝 Memory Format
100
170
 
101
171
  Each memory is a Markdown file with YAML frontmatter:
102
172
 
@@ -113,7 +183,7 @@ Skip post-action summaries. User reads diffs directly.
113
183
  **How to apply:** Don't summarize changes at the end of responses.
114
184
  ```
115
185
 
116
- ### Memory types
186
+ ### Memory Types
117
187
 
118
188
  | Type | Description |
119
189
  |---|---|
@@ -122,44 +192,19 @@ Skip post-action summaries. User reads diffs directly.
122
192
  | `project` | Ongoing work context not derivable from code |
123
193
  | `reference` | Pointers to external resources |
124
194
 
125
- ### Index file
195
+ <details>
196
+ <summary>📄 Index file (MEMORY.md)</summary>
126
197
 
127
- `MEMORY.md` is an index (not content storage). Each entry is one line:
198
+ `MEMORY.md` is an auto-managed index (not content storage). Each entry is one line:
128
199
 
129
200
  ```markdown
130
201
  - [User prefers terse responses](feedback_terse_responses.md) — Skip summaries, user reads diffs
131
202
  - [User is a data scientist](user_role.md) — Focus on observability/logging context
132
203
  ```
133
204
 
134
- ## 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
- ```
205
+ </details>
161
206
 
162
- ## Tools Reference
207
+ ## 🔧 Tools Reference
163
208
 
164
209
  ### `memory_save`
165
210
 
@@ -167,11 +212,11 @@ Save or update a memory.
167
212
 
168
213
  | Parameter | Type | Required | Description |
169
214
  |---|---|---|---|
170
- | `file_name` | string | 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 |
215
+ | `file_name` | string | | File name slug (e.g., `user_role`) |
216
+ | `name` | string | | Short title |
217
+ | `description` | string | | One-line description for relevance matching |
218
+ | `type` | enum | | `user`, `feedback`, `project`, or `reference` |
219
+ | `content` | string | | Memory content |
175
220
 
176
221
  ### `memory_delete`
177
222
 
@@ -189,6 +234,6 @@ Search memories by keyword across name, description, and content.
189
234
 
190
235
  Read the full content of a specific memory file.
191
236
 
192
- ## License
237
+ ## 📄 License
193
238
 
194
- MIT
239
+ [MIT](LICENSE) © [kuitos](https://github.com/kuitos)
package/bin/opencode CHANGED
@@ -125,6 +125,22 @@ log() {
125
125
  echo "[opencode-memory] $*" >&2
126
126
  }
127
127
 
128
+ has_new_memories() {
129
+ # Check if any memory file was modified during the session
130
+ # Checks all projects' memory directories for files newer than the timestamp marker
131
+ local mem_base="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
132
+
133
+ if [ ! -d "$mem_base" ]; then
134
+ return 1
135
+ fi
136
+
137
+ # Find any .md file under projects/*/memory/ newer than our timestamp
138
+ local newer_files
139
+ newer_files=$(find "$mem_base" -path "*/memory/*.md" -newer "$TIMESTAMP_FILE" 2>/dev/null | head -1)
140
+
141
+ [ -n "$newer_files" ]
142
+ }
143
+
128
144
  get_latest_session_id() {
129
145
  local session_json
130
146
  session_json=$("$REAL_OPENCODE" session list --format json -n 1 2>/dev/null) || return 1
@@ -157,6 +173,10 @@ release_lock() {
157
173
  rm -f "$LOCK_FILE"
158
174
  }
159
175
 
176
+ cleanup_timestamp() {
177
+ rm -f "$TIMESTAMP_FILE"
178
+ }
179
+
160
180
  run_extraction() {
161
181
  local session_id="$1"
162
182
 
@@ -190,12 +210,16 @@ run_extraction() {
190
210
  # Main
191
211
  # ============================================================================
192
212
 
213
+ # Step 0: Create timestamp marker before running opencode
214
+ TIMESTAMP_FILE=$(mktemp)
215
+
193
216
  # Step 1: Run the real opencode with all original arguments, capture exit code
194
217
  opencode_exit=0
195
218
  "$REAL_OPENCODE" "$@" || opencode_exit=$?
196
219
 
197
220
  # Step 2: Check if extraction is enabled
198
221
  if [ "$EXTRACT_ENABLED" = "0" ]; then
222
+ cleanup_timestamp
199
223
  exit $opencode_exit
200
224
  fi
201
225
 
@@ -204,11 +228,20 @@ session_id=$(get_latest_session_id)
204
228
 
205
229
  if [ -z "$session_id" ]; then
206
230
  log "No session found, skipping memory extraction"
231
+ cleanup_timestamp
232
+ exit $opencode_exit
233
+ fi
234
+
235
+ # Step 3.5: Check if memories were already written during the session
236
+ if has_new_memories; then
237
+ log "Main agent already wrote memories during session, skipping extraction"
238
+ cleanup_timestamp
207
239
  exit $opencode_exit
208
240
  fi
209
241
 
210
242
  # Step 4: Acquire lock (prevent concurrent extractions)
211
243
  if ! acquire_lock; then
244
+ cleanup_timestamp
212
245
  exit $opencode_exit
213
246
  fi
214
247
 
@@ -216,11 +249,13 @@ fi
216
249
  if [ "$FOREGROUND" = "1" ]; then
217
250
  # Foreground mode (for debugging)
218
251
  run_extraction "$session_id"
252
+ cleanup_timestamp
219
253
  else
220
254
  # Background mode (default) — user isn't blocked
221
255
  run_extraction "$session_id" &
222
256
  disown
223
257
  log "Memory extraction started in background (PID $!)"
258
+ cleanup_timestamp
224
259
  fi
225
260
 
226
261
  exit $opencode_exit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claude-memory",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Cross-session memory plugin for OpenCode — Claude Code compatible, persistent, file-based memory",
6
6
  "main": "src/index.ts",
@@ -33,5 +33,9 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "zod": "^3.24.0"
36
+ },
37
+ "devDependencies": {
38
+ "bun-types": "^1.3.11",
39
+ "@opencode-ai/plugin": "^1.3.10"
36
40
  }
37
41
  }
package/src/index.ts CHANGED
@@ -1,15 +1,14 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { tool } from "@opencode-ai/plugin"
3
3
  import { buildMemorySystemPrompt } from "./prompt.js"
4
+ import { recallRelevantMemories, formatRecalledMemories } from "./recall.js"
4
5
  import {
5
6
  saveMemory,
6
7
  deleteMemory,
7
8
  listMemories,
8
9
  searchMemories,
9
10
  readMemory,
10
- readIndex,
11
11
  MEMORY_TYPES,
12
- type MemoryType,
13
12
  } from "./memory.js"
14
13
  import { getMemoryDir } from "./paths.js"
15
14
 
@@ -18,7 +17,26 @@ export const MemoryPlugin: Plugin = async ({ worktree }) => {
18
17
 
19
18
  return {
20
19
  "experimental.chat.system.transform": async (_input, output) => {
21
- 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
+ }