opencode-claude-memory 1.0.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/bin/opencode +35 -0
- package/package.json +5 -1
- package/src/index.ts +21 -3
- package/src/memory.ts +30 -6
- package/src/paths.ts +29 -0
- package/src/prompt.ts +6 -2
- package/src/recall.ts +126 -0
package/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": "1.
|
|
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
|
-
|
|
20
|
+
let query: string | undefined
|
|
21
|
+
if (_input && typeof _input === "object") {
|
|
22
|
+
const messages = (_input as { messages?: unknown }).messages
|
|
23
|
+
if (Array.isArray(messages)) {
|
|
24
|
+
const lastUserMsg = [...messages]
|
|
25
|
+
.reverse()
|
|
26
|
+
.find((message) =>
|
|
27
|
+
message && typeof message === "object" && "role" in message && (message as { role?: unknown }).role === "user",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if (lastUserMsg && typeof lastUserMsg === "object" && "content" in lastUserMsg) {
|
|
31
|
+
const content = (lastUserMsg as { content?: unknown }).content
|
|
32
|
+
query = typeof content === "string" ? content : JSON.stringify(content)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const recalled = recallRelevantMemories(worktree, query)
|
|
38
|
+
const recalledSection = formatRecalledMemories(recalled)
|
|
39
|
+
const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection)
|
|
22
40
|
output.system.push(memoryPrompt)
|
|
23
41
|
},
|
|
24
42
|
|
package/src/memory.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "fs"
|
|
2
2
|
import { join, basename } from "path"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getMemoryDir,
|
|
5
|
+
getMemoryEntrypoint,
|
|
6
|
+
ENTRYPOINT_NAME,
|
|
7
|
+
validateMemoryFileName,
|
|
8
|
+
MAX_MEMORY_FILES,
|
|
9
|
+
MAX_MEMORY_FILE_BYTES,
|
|
10
|
+
FRONTMATTER_MAX_LINES,
|
|
11
|
+
} from "./paths.js"
|
|
4
12
|
|
|
5
13
|
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"] as const
|
|
6
14
|
export type MemoryType = (typeof MEMORY_TYPES)[number]
|
|
@@ -21,11 +29,20 @@ function parseFrontmatter(raw: string): { frontmatter: Record<string, string>; c
|
|
|
21
29
|
return { frontmatter: {}, content: trimmed }
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
const
|
|
25
|
-
|
|
32
|
+
const lines = trimmed.split("\n")
|
|
33
|
+
let closingLineIdx = -1
|
|
34
|
+
for (let i = 1; i < Math.min(lines.length, FRONTMATTER_MAX_LINES); i++) {
|
|
35
|
+
if (lines[i].trimEnd() === "---") {
|
|
36
|
+
closingLineIdx = i
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (closingLineIdx === -1) {
|
|
26
41
|
return { frontmatter: {}, content: trimmed }
|
|
27
42
|
}
|
|
28
43
|
|
|
44
|
+
const endIndex = lines.slice(0, closingLineIdx).join("\n").length + 1
|
|
45
|
+
|
|
29
46
|
const frontmatterBlock = trimmed.slice(3, endIndex).trim()
|
|
30
47
|
const content = trimmed.slice(endIndex + 3).trim()
|
|
31
48
|
|
|
@@ -61,6 +78,7 @@ export function listMemories(worktree: string): MemoryEntry[] {
|
|
|
61
78
|
files = readdirSync(memDir)
|
|
62
79
|
.filter((f) => f.endsWith(".md") && f !== ENTRYPOINT_NAME)
|
|
63
80
|
.sort()
|
|
81
|
+
.slice(0, MAX_MEMORY_FILES)
|
|
64
82
|
} catch {
|
|
65
83
|
return entries
|
|
66
84
|
}
|
|
@@ -88,8 +106,9 @@ export function listMemories(worktree: string): MemoryEntry[] {
|
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
export function readMemory(worktree: string, fileName: string): MemoryEntry | null {
|
|
109
|
+
const safeName = validateMemoryFileName(fileName)
|
|
91
110
|
const memDir = getMemoryDir(worktree)
|
|
92
|
-
const filePath = join(memDir,
|
|
111
|
+
const filePath = join(memDir, safeName)
|
|
93
112
|
|
|
94
113
|
try {
|
|
95
114
|
const rawContent = readFileSync(filePath, "utf-8")
|
|
@@ -116,11 +135,16 @@ export function saveMemory(
|
|
|
116
135
|
type: MemoryType,
|
|
117
136
|
content: string,
|
|
118
137
|
): string {
|
|
138
|
+
const safeName = validateMemoryFileName(fileName)
|
|
119
139
|
const memDir = getMemoryDir(worktree)
|
|
120
|
-
const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
|
|
121
140
|
const filePath = join(memDir, safeName)
|
|
122
141
|
|
|
123
142
|
const fileContent = `${buildFrontmatter(name, description, type)}\n\n${content.trim()}\n`
|
|
143
|
+
if (Buffer.byteLength(fileContent, "utf-8") > MAX_MEMORY_FILE_BYTES) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Memory file content exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
124
148
|
writeFileSync(filePath, fileContent, "utf-8")
|
|
125
149
|
|
|
126
150
|
updateIndex(worktree, safeName, name, description)
|
|
@@ -129,8 +153,8 @@ export function saveMemory(
|
|
|
129
153
|
}
|
|
130
154
|
|
|
131
155
|
export function deleteMemory(worktree: string, fileName: string): boolean {
|
|
156
|
+
const safeName = validateMemoryFileName(fileName)
|
|
132
157
|
const memDir = getMemoryDir(worktree)
|
|
133
|
-
const safeName = fileName.endsWith(".md") ? fileName : `${fileName}.md`
|
|
134
158
|
const filePath = join(memDir, safeName)
|
|
135
159
|
|
|
136
160
|
try {
|
package/src/paths.ts
CHANGED
|
@@ -10,6 +10,35 @@ export const ENTRYPOINT_NAME = "MEMORY.md"
|
|
|
10
10
|
export const MAX_ENTRYPOINT_LINES = 200
|
|
11
11
|
export const MAX_ENTRYPOINT_BYTES = 25_000
|
|
12
12
|
|
|
13
|
+
export const MAX_MEMORY_FILES = 200
|
|
14
|
+
export const MAX_MEMORY_FILE_BYTES = 40_000
|
|
15
|
+
export const FRONTMATTER_MAX_LINES = 30
|
|
16
|
+
|
|
17
|
+
export function validateMemoryFileName(fileName: string): string {
|
|
18
|
+
const base = fileName.endsWith(".md") ? fileName.slice(0, -3) : fileName
|
|
19
|
+
|
|
20
|
+
if (base.length === 0) {
|
|
21
|
+
throw new Error("Memory file name cannot be empty")
|
|
22
|
+
}
|
|
23
|
+
if (base.includes("/") || base.includes("\\")) {
|
|
24
|
+
throw new Error(`Memory file name must not contain path separators: ${fileName}`)
|
|
25
|
+
}
|
|
26
|
+
if (base.includes("..")) {
|
|
27
|
+
throw new Error(`Memory file name must not contain path traversal: ${fileName}`)
|
|
28
|
+
}
|
|
29
|
+
if (base.includes("\0")) {
|
|
30
|
+
throw new Error(`Memory file name must not contain null bytes: ${fileName}`)
|
|
31
|
+
}
|
|
32
|
+
if (base.startsWith(".")) {
|
|
33
|
+
throw new Error(`Memory file name must not start with '.': ${fileName}`)
|
|
34
|
+
}
|
|
35
|
+
if (base.toUpperCase() === "MEMORY") {
|
|
36
|
+
throw new Error(`'MEMORY' is a reserved name and cannot be used as a memory file name`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return `${base}.md`
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
const MAX_SANITIZED_LENGTH = 200
|
|
14
43
|
|
|
15
44
|
// Exact copy of Claude Code's djb2Hash() from utils/hash.ts
|
package/src/prompt.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MEMORY_TYPES } from "./memory.js"
|
|
2
|
-
import { readIndex, truncateEntrypoint
|
|
2
|
+
import { readIndex, truncateEntrypoint } from "./memory.js"
|
|
3
3
|
import { getMemoryDir, ENTRYPOINT_NAME } from "./paths.js"
|
|
4
4
|
|
|
5
5
|
const FRONTMATTER_EXAMPLE = `\`\`\`markdown
|
|
@@ -89,7 +89,7 @@ A memory that names a specific function, file, or flag is a claim that it existe
|
|
|
89
89
|
|
|
90
90
|
A memory that summarizes repo state is frozen in time. If the user asks about *recent* or *current* state, prefer \`git log\` or reading the code over recalling the snapshot.`
|
|
91
91
|
|
|
92
|
-
export function buildMemorySystemPrompt(worktree: string): string {
|
|
92
|
+
export function buildMemorySystemPrompt(worktree: string, recalledMemoriesSection?: string): string {
|
|
93
93
|
const memoryDir = getMemoryDir(worktree)
|
|
94
94
|
const indexContent = readIndex(worktree)
|
|
95
95
|
|
|
@@ -139,5 +139,9 @@ export function buildMemorySystemPrompt(worktree: string): string {
|
|
|
139
139
|
)
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
if (recalledMemoriesSection?.trim()) {
|
|
143
|
+
lines.push("", recalledMemoriesSection)
|
|
144
|
+
}
|
|
145
|
+
|
|
142
146
|
return lines.join("\n")
|
|
143
147
|
}
|
package/src/recall.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { statSync } from "fs"
|
|
2
|
+
import { listMemories, type MemoryEntry } from "./memory.js"
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder()
|
|
5
|
+
|
|
6
|
+
export type RecalledMemory = {
|
|
7
|
+
fileName: string
|
|
8
|
+
name: string
|
|
9
|
+
type: string
|
|
10
|
+
description: string
|
|
11
|
+
content: string
|
|
12
|
+
ageInDays: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_RECALLED_MEMORIES = 5
|
|
16
|
+
const MAX_MEMORY_LINES = 200
|
|
17
|
+
const MAX_MEMORY_BYTES = 4096
|
|
18
|
+
|
|
19
|
+
function tokenizeQuery(query: string): string[] {
|
|
20
|
+
return [...new Set(query.toLowerCase().split(/\s+/).map((token) => token.trim()).filter((token) => token.length >= 2))]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getMemoryMtimeMs(entry: MemoryEntry): number {
|
|
24
|
+
try {
|
|
25
|
+
return statSync(entry.filePath).mtimeMs
|
|
26
|
+
} catch {
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scoreMemory(entry: MemoryEntry, terms: string[]): number {
|
|
32
|
+
if (terms.length === 0) return 0
|
|
33
|
+
const haystack = `${entry.name}\n${entry.description}\n${entry.content}`.toLowerCase()
|
|
34
|
+
let score = 0
|
|
35
|
+
for (const term of terms) {
|
|
36
|
+
if (haystack.includes(term)) score += 1
|
|
37
|
+
}
|
|
38
|
+
return score
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncateMemoryContent(content: string): string {
|
|
42
|
+
const maxLines = content.split("\n").slice(0, MAX_MEMORY_LINES)
|
|
43
|
+
const lineTruncated = maxLines.join("\n")
|
|
44
|
+
if (encoder.encode(lineTruncated).length <= MAX_MEMORY_BYTES) {
|
|
45
|
+
return lineTruncated
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lines = lineTruncated.split("\n")
|
|
49
|
+
const kept: string[] = []
|
|
50
|
+
let usedBytes = 0
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const candidate = kept.length === 0 ? line : `\n${line}`
|
|
54
|
+
const candidateBytes = encoder.encode(candidate).length
|
|
55
|
+
if (usedBytes + candidateBytes > MAX_MEMORY_BYTES) break
|
|
56
|
+
kept.push(line)
|
|
57
|
+
usedBytes += candidateBytes
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return kept.join("\n")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function recallRelevantMemories(worktree: string, query?: string): RecalledMemory[] {
|
|
64
|
+
const memories = listMemories(worktree)
|
|
65
|
+
if (memories.length === 0) return []
|
|
66
|
+
|
|
67
|
+
const now = Date.now()
|
|
68
|
+
const memoriesWithMeta = memories.map((entry) => {
|
|
69
|
+
const mtimeMs = getMemoryMtimeMs(entry)
|
|
70
|
+
return {
|
|
71
|
+
entry,
|
|
72
|
+
mtimeMs,
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const terms = query ? tokenizeQuery(query) : []
|
|
77
|
+
|
|
78
|
+
let selected = memoriesWithMeta
|
|
79
|
+
|
|
80
|
+
if (terms.length > 0) {
|
|
81
|
+
const withScores = memoriesWithMeta
|
|
82
|
+
.map((item) => ({
|
|
83
|
+
...item,
|
|
84
|
+
score: scoreMemory(item.entry, terms),
|
|
85
|
+
}))
|
|
86
|
+
.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs)
|
|
87
|
+
|
|
88
|
+
if (withScores.some((item) => item.score > 0)) {
|
|
89
|
+
selected = withScores
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (selected === memoriesWithMeta) {
|
|
94
|
+
selected = [...memoriesWithMeta].sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return selected.slice(0, MAX_RECALLED_MEMORIES).map(({ entry, mtimeMs }) => ({
|
|
98
|
+
fileName: entry.fileName,
|
|
99
|
+
name: entry.name,
|
|
100
|
+
type: entry.type,
|
|
101
|
+
description: entry.description,
|
|
102
|
+
content: truncateMemoryContent(entry.content),
|
|
103
|
+
ageInDays: Math.max(0, Math.floor((now - mtimeMs) / (1000 * 60 * 60 * 24))),
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatAgeWarning(ageInDays: number): string {
|
|
108
|
+
if (ageInDays <= 1) return ""
|
|
109
|
+
return `\n> ⚠️ This memory is ${ageInDays} days old. Memories are point-in-time observations, not live state — claims about code behavior or file:line citations may be outdated. Verify against current code before asserting as fact.\n`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatRecalledMemories(memories: RecalledMemory[]): string {
|
|
113
|
+
if (memories.length === 0) return ""
|
|
114
|
+
|
|
115
|
+
const sections = memories.map((memory) => {
|
|
116
|
+
const ageWarning = formatAgeWarning(memory.ageInDays)
|
|
117
|
+
return `### ${memory.name} (${memory.type})${ageWarning}\n${memory.content}`
|
|
118
|
+
})
|
|
119
|
+
return [
|
|
120
|
+
"## Recalled Memories",
|
|
121
|
+
"",
|
|
122
|
+
"The following memories were automatically selected as relevant to this conversation. They may be outdated — verify against current state before relying on them.",
|
|
123
|
+
"",
|
|
124
|
+
sections.join("\n\n"),
|
|
125
|
+
].join("\n")
|
|
126
|
+
}
|