opencode-claude-memory 1.5.1 → 1.6.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/src/index.ts CHANGED
@@ -12,31 +12,180 @@ import {
12
12
  } from "./memory.js"
13
13
  import { getMemoryDir } from "./paths.js"
14
14
 
15
+ // Per-turn derived state — overwritten each time messages.transform fires.
16
+ // This replaces the old process-global session Maps so that compact naturally
17
+ // resets both alreadySurfaced and recentTools (the messages shrink after compact,
18
+ // so the derived state shrinks with them).
19
+ type TurnContext = {
20
+ query?: string
21
+ alreadySurfaced: Set<string>
22
+ recentTools: string[]
23
+ }
24
+
25
+ const turnContextBySession = new Map<string, TurnContext>()
26
+
27
+ function shouldIgnoreMemoryContext(query: string | undefined): boolean {
28
+ if (process.env.OPENCODE_MEMORY_IGNORE === "1") return true
29
+ if (!query) return false
30
+
31
+ const normalized = query.toLowerCase()
32
+ return (
33
+ /(ignore|don't use|do not use|without|skip)\s+(the\s+)?memory/.test(normalized) ||
34
+ /memory\s+(should be|must be)?\s*ignored/.test(normalized)
35
+ )
36
+ }
37
+
38
+ function extractUserQuery(message: unknown): string | undefined {
39
+ if (!message || typeof message !== "object") return undefined
40
+
41
+ if ("content" in message) {
42
+ const content = (message as { content?: unknown }).content
43
+ if (typeof content === "string") return content
44
+ if (content !== undefined) return JSON.stringify(content)
45
+ }
46
+
47
+ if ("parts" in message) {
48
+ const parts = (message as { parts?: unknown }).parts
49
+ if (Array.isArray(parts)) {
50
+ const text = parts
51
+ .map((part) => {
52
+ if (!part || typeof part !== "object") return ""
53
+ return typeof (part as { text?: unknown }).text === "string"
54
+ ? (part as { text: string }).text
55
+ : ""
56
+ })
57
+ .filter(Boolean)
58
+ .join("\n")
59
+ .trim()
60
+ if (text) return text
61
+ }
62
+ }
63
+
64
+ return undefined
65
+ }
66
+
67
+ function getLastUserQuery(messages: Array<{ info?: { role?: unknown; sessionID?: unknown }; parts?: unknown }>): {
68
+ query?: string
69
+ sessionID?: string
70
+ } {
71
+ for (let i = messages.length - 1; i >= 0; i--) {
72
+ const message = messages[i]
73
+ if (message?.info?.role !== "user") continue
74
+
75
+ const query = extractUserQuery(message)
76
+ const sessionID = typeof message.info?.sessionID === "string" ? message.info.sessionID : undefined
77
+ return { query, sessionID }
78
+ }
79
+
80
+ return {}
81
+ }
82
+
83
+ function isAutoMemoryPart(part: unknown): boolean {
84
+ if (!part || typeof part !== "object") return false
85
+ return typeof (part as { text?: unknown }).text === "string" &&
86
+ (part as { text: string }).text.includes("# Auto Memory")
87
+ }
88
+
89
+ // Parses "### <name> (<type>)" headers from the ## Recalled Memories section
90
+ // of system prompts. After compaction old system messages disappear, so
91
+ // the returned set naturally shrinks — no manual reset needed.
92
+ function extractSurfacedMemoryKeys(systemText: string): Set<string> {
93
+ const keys = new Set<string>()
94
+ const recalledSection = systemText.indexOf("## Recalled Memories")
95
+ if (recalledSection === -1) return keys
96
+
97
+ const headerPattern = /^### (.+?) \((\w+)\)/gm
98
+ const section = systemText.slice(recalledSection)
99
+ for (let match = headerPattern.exec(section); match !== null; match = headerPattern.exec(section)) {
100
+ keys.add(`${match[1]}|${match[2]}`)
101
+ }
102
+ return keys
103
+ }
104
+
105
+ // Only completed tools — matches Claude Code's collectRecentSuccessfulTools().
106
+ function extractRecentTools(
107
+ messages: Array<{ info?: { role?: unknown }; parts?: unknown[] }>,
108
+ ): string[] {
109
+ const tools: string[] = []
110
+ const seen = new Set<string>()
111
+ for (const message of messages) {
112
+ if (!message.parts || !Array.isArray(message.parts)) continue
113
+ for (const part of message.parts) {
114
+ if (!part || typeof part !== "object") continue
115
+ const p = part as { type?: string; tool?: string; state?: { status?: string } }
116
+ if (p.type !== "tool" || !p.tool) continue
117
+ if (p.state?.status !== "completed") continue
118
+ if (seen.has(p.tool)) continue
119
+ seen.add(p.tool)
120
+ tools.push(p.tool)
121
+ }
122
+ }
123
+ return tools
124
+ }
125
+
15
126
  export const MemoryPlugin: Plugin = async ({ worktree }) => {
16
127
  getMemoryDir(worktree)
17
128
 
18
129
  return {
19
- "experimental.chat.system.transform": async (_input, output) => {
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)
130
+ "experimental.chat.messages.transform": async (_input, output) => {
131
+ const { query, sessionID } = getLastUserQuery(output.messages)
132
+
133
+ if (sessionID) {
134
+ const alreadySurfaced = new Set<string>()
135
+ for (const message of output.messages) {
136
+ const role = String(message.info.role)
137
+ if (role !== "system") continue
138
+ for (const part of message.parts) {
139
+ if (!part || typeof part !== "object") continue
140
+ const text = (part as { text?: string }).text
141
+ if (typeof text === "string") {
142
+ for (const key of extractSurfacedMemoryKeys(text)) {
143
+ alreadySurfaced.add(key)
144
+ }
145
+ }
33
146
  }
34
147
  }
148
+
149
+ const recentTools = extractRecentTools(
150
+ output.messages as Array<{ info?: { role?: unknown }; parts?: unknown[] }>,
151
+ )
152
+
153
+ turnContextBySession.set(sessionID, { query, alreadySurfaced, recentTools })
35
154
  }
36
155
 
37
- const recalled = recallRelevantMemories(worktree, query)
156
+ if (shouldIgnoreMemoryContext(query)) {
157
+ output.messages = output.messages
158
+ .map((message) => {
159
+ const role = String(message.info.role)
160
+ if (role !== "system") return message
161
+
162
+ const parts = message.parts.filter((part) => !isAutoMemoryPart(part))
163
+ return { ...message, parts }
164
+ })
165
+ .filter((message) => message.parts.length > 0)
166
+ }
167
+ },
168
+
169
+ "experimental.chat.system.transform": async (_input, output) => {
170
+ let sessionID: string | undefined
171
+ if (_input && typeof _input === "object") {
172
+ sessionID = (typeof (_input as { sessionID?: unknown }).sessionID === "string"
173
+ ? (_input as { sessionID?: string }).sessionID
174
+ : undefined)
175
+ }
176
+
177
+ const ctx = sessionID ? turnContextBySession.get(sessionID) : undefined
178
+ const query = ctx?.query
179
+ const alreadySurfaced = ctx?.alreadySurfaced ?? new Set<string>()
180
+ const recentTools = ctx?.recentTools ?? []
181
+
182
+ const ignoreMemoryContext = process.env.OPENCODE_MEMORY_IGNORE === "1" || shouldIgnoreMemoryContext(query)
183
+ const recalled = ignoreMemoryContext ? [] : recallRelevantMemories(worktree, query, alreadySurfaced, recentTools)
184
+
38
185
  const recalledSection = formatRecalledMemories(recalled)
39
- const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection)
186
+ const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection, {
187
+ includeIndex: !ignoreMemoryContext,
188
+ })
40
189
  output.system.push(memoryPrompt)
41
190
  },
42
191
 
package/src/memory.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from "fs"
1
+ import { readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs"
2
2
  import { join, basename } from "path"
3
3
  import {
4
4
  getMemoryDir,
@@ -7,6 +7,8 @@ import {
7
7
  validateMemoryFileName,
8
8
  MAX_MEMORY_FILES,
9
9
  MAX_MEMORY_FILE_BYTES,
10
+ MAX_ENTRYPOINT_LINES,
11
+ MAX_ENTRYPOINT_BYTES,
10
12
  FRONTMATTER_MAX_LINES,
11
13
  } from "./paths.js"
12
14
 
@@ -75,7 +77,7 @@ export function listMemories(worktree: string): MemoryEntry[] {
75
77
 
76
78
  let files: string[]
77
79
  try {
78
- files = readdirSync(memDir)
80
+ files = readdirSync(memDir, { encoding: "utf-8" })
79
81
  .filter((f) => f.endsWith(".md") && f !== ENTRYPOINT_NAME)
80
82
  .sort()
81
83
  .slice(0, MAX_MEMORY_FILES)
@@ -214,30 +216,57 @@ function removeFromIndex(worktree: string, fileName: string): void {
214
216
  writeFileSync(entrypoint, lines.length > 0 ? lines.join("\n") + "\n" : "", "utf-8")
215
217
  }
216
218
 
217
- export function truncateEntrypoint(raw: string): { content: string; wasTruncated: boolean } {
219
+ function formatFileSize(bytes: number): string {
220
+ if (bytes < 1024) return `${bytes}B`
221
+ return `${(bytes / 1024).toFixed(1)}KB`
222
+ }
223
+
224
+ export type EntrypointTruncation = {
225
+ content: string
226
+ lineCount: number
227
+ byteCount: number
228
+ wasLineTruncated: boolean
229
+ wasByteTruncated: boolean
230
+ }
231
+
232
+ // Port of Claude Code's truncateEntrypointContent() from memdir.ts.
233
+ // Uses .length (char count, same as Claude Code) for byte measurement.
234
+ export function truncateEntrypoint(raw: string): EntrypointTruncation {
218
235
  const trimmed = raw.trim()
219
- if (!trimmed) return { content: "", wasTruncated: false }
236
+ if (!trimmed) return { content: "", lineCount: 0, byteCount: 0, wasLineTruncated: false, wasByteTruncated: false }
220
237
 
221
- const lines = trimmed.split("\n")
222
- const lineCount = lines.length
238
+ const contentLines = trimmed.split("\n")
239
+ const lineCount = contentLines.length
223
240
  const byteCount = trimmed.length
224
241
 
225
- const wasLineTruncated = lineCount > 200
226
- const wasByteTruncated = byteCount > 25_000
242
+ const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES
243
+ const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES
227
244
 
228
245
  if (!wasLineTruncated && !wasByteTruncated) {
229
- return { content: trimmed, wasTruncated: false }
246
+ return { content: trimmed, lineCount, byteCount, wasLineTruncated, wasByteTruncated }
230
247
  }
231
248
 
232
- let truncated = wasLineTruncated ? lines.slice(0, 200).join("\n") : trimmed
249
+ let truncated = wasLineTruncated
250
+ ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join("\n")
251
+ : trimmed
233
252
 
234
- if (truncated.length > 25_000) {
235
- const cutAt = truncated.lastIndexOf("\n", 25_000)
236
- truncated = truncated.slice(0, cutAt > 0 ? cutAt : 25_000)
253
+ if (truncated.length > MAX_ENTRYPOINT_BYTES) {
254
+ const cutAt = truncated.lastIndexOf("\n", MAX_ENTRYPOINT_BYTES)
255
+ truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES)
237
256
  }
238
257
 
258
+ const reason =
259
+ wasByteTruncated && !wasLineTruncated
260
+ ? `${formatFileSize(byteCount)} (limit: ${formatFileSize(MAX_ENTRYPOINT_BYTES)}) — index entries are too long`
261
+ : wasLineTruncated && !wasByteTruncated
262
+ ? `${lineCount} lines (limit: ${MAX_ENTRYPOINT_LINES})`
263
+ : `${lineCount} lines and ${formatFileSize(byteCount)}`
264
+
239
265
  return {
240
- content: truncated + "\n\n> WARNING: MEMORY.md was truncated. Keep index entries concise.",
241
- wasTruncated: true,
266
+ content: truncated + `\n\n> WARNING: ${ENTRYPOINT_NAME} is ${reason}. Only part of it was loaded. Keep index entries to one line under ~200 chars; move detail into topic files.`,
267
+ lineCount,
268
+ byteCount,
269
+ wasLineTruncated,
270
+ wasByteTruncated,
242
271
  }
243
272
  }
@@ -0,0 +1,130 @@
1
+ import { readdirSync, readFileSync, statSync } from "fs"
2
+ import { basename, join } from "path"
3
+ import {
4
+ getMemoryDir,
5
+ ENTRYPOINT_NAME,
6
+ MAX_MEMORY_FILES,
7
+ FRONTMATTER_MAX_LINES,
8
+ } from "./paths.js"
9
+ import type { MemoryType } from "./memory.js"
10
+
11
+ export type MemoryHeader = {
12
+ filename: string
13
+ filePath: string
14
+ mtimeMs: number
15
+ name: string | null
16
+ description: string | null
17
+ type: MemoryType | undefined
18
+ }
19
+
20
+ const MEMORY_TYPES: readonly string[] = ["user", "feedback", "project", "reference"]
21
+
22
+ function parseMemoryType(raw: string | undefined): MemoryType | undefined {
23
+ if (!raw) return undefined
24
+ return MEMORY_TYPES.includes(raw) ? (raw as MemoryType) : undefined
25
+ }
26
+
27
+ function readFileHeader(filePath: string, maxLines: number): { content: string; mtimeMs: number } {
28
+ try {
29
+ const raw = readFileSync(filePath, "utf-8")
30
+ const stat = statSync(filePath)
31
+ const lines = raw.split("\n")
32
+ const header = lines.slice(0, maxLines).join("\n")
33
+ return { content: header, mtimeMs: stat.mtimeMs }
34
+ } catch {
35
+ return { content: "", mtimeMs: 0 }
36
+ }
37
+ }
38
+
39
+ function parseFrontmatterHeader(raw: string): Record<string, string> {
40
+ const trimmed = raw.trim()
41
+ if (!trimmed.startsWith("---")) {
42
+ return {}
43
+ }
44
+
45
+ const lines = trimmed.split("\n")
46
+ let closingLineIdx = -1
47
+ for (let i = 1; i < lines.length; i++) {
48
+ if (lines[i].trimEnd() === "---") {
49
+ closingLineIdx = i
50
+ break
51
+ }
52
+ }
53
+ if (closingLineIdx === -1) {
54
+ return {}
55
+ }
56
+
57
+ const frontmatter: Record<string, string> = {}
58
+ for (let i = 1; i < closingLineIdx; i++) {
59
+ const line = lines[i]
60
+ const colonIdx = line.indexOf(":")
61
+ if (colonIdx === -1) continue
62
+ const key = line.slice(0, colonIdx).trim()
63
+ const value = line.slice(colonIdx + 1).trim()
64
+ if (key && value) {
65
+ frontmatter[key] = value
66
+ }
67
+ }
68
+
69
+ return frontmatter
70
+ }
71
+
72
+ /**
73
+ * Recursive scan of memory directory. Reads only frontmatter (first N lines),
74
+ * returns headers sorted by mtime desc, capped at MAX_MEMORY_FILES.
75
+ * Port of Claude Code's scanMemoryFiles().
76
+ */
77
+ export function scanMemoryFiles(memoryDir: string): MemoryHeader[] {
78
+ try {
79
+ const entries = readdirSync(memoryDir, { recursive: true, encoding: "utf-8" }) as string[]
80
+ const mdFiles = entries.filter(
81
+ (f: string) => f.endsWith(".md") && basename(f) !== ENTRYPOINT_NAME,
82
+ )
83
+
84
+ const headers: MemoryHeader[] = []
85
+ for (const relativePath of mdFiles) {
86
+ const filePath = join(memoryDir, relativePath)
87
+ try {
88
+ const { content, mtimeMs } = readFileHeader(filePath, FRONTMATTER_MAX_LINES)
89
+ const frontmatter = parseFrontmatterHeader(content)
90
+ headers.push({
91
+ filename: relativePath,
92
+ filePath,
93
+ mtimeMs,
94
+ name: frontmatter.name || null,
95
+ description: frontmatter.description || null,
96
+ type: parseMemoryType(frontmatter.type),
97
+ })
98
+ } catch {
99
+ // skip unreadable files
100
+ }
101
+ }
102
+
103
+ return headers
104
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
105
+ .slice(0, MAX_MEMORY_FILES)
106
+ } catch {
107
+ return []
108
+ }
109
+ }
110
+
111
+ // Port of Claude Code's formatMemoryManifest():
112
+ // `- [type] filename (ISO timestamp): description` per line
113
+ export function formatMemoryManifest(memories: MemoryHeader[]): string {
114
+ return memories
115
+ .map((m) => {
116
+ const tag = m.type ? `[${m.type}] ` : ""
117
+ const ts = new Date(m.mtimeMs).toISOString()
118
+ return m.description
119
+ ? `- ${tag}${m.filename} (${ts}): ${m.description}`
120
+ : `- ${tag}${m.filename} (${ts})`
121
+ })
122
+ .join("\n")
123
+ }
124
+
125
+ export function getMemoryManifest(worktree: string): { headers: MemoryHeader[]; manifest: string } {
126
+ const memoryDir = getMemoryDir(worktree)
127
+ const headers = scanMemoryFiles(memoryDir)
128
+ const manifest = formatMemoryManifest(headers)
129
+ return { headers, manifest }
130
+ }
package/src/paths.ts CHANGED
@@ -141,10 +141,13 @@ function getClaudeConfigHomeDir(): string {
141
141
  return (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude")).normalize("NFC")
142
142
  }
143
143
 
144
- export function getMemoryDir(worktree: string): string {
144
+ export function getProjectDir(worktree: string): string {
145
145
  const canonicalRoot = findCanonicalGitRoot(worktree) ?? worktree
146
- const projectsDir = join(getClaudeConfigHomeDir(), "projects")
147
- const memoryDir = join(projectsDir, sanitizePath(canonicalRoot), "memory")
146
+ return join(getClaudeConfigHomeDir(), "projects", sanitizePath(canonicalRoot))
147
+ }
148
+
149
+ export function getMemoryDir(worktree: string): string {
150
+ const memoryDir = join(getProjectDir(worktree), "memory")
148
151
  ensureDir(memoryDir)
149
152
  return memoryDir
150
153
  }