opencode-claude-memory 1.6.0 → 1.6.2
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 +3 -1
- package/bin/opencode-memory +90 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +239 -0
- package/dist/memory.d.ts +25 -0
- package/dist/memory.js +200 -0
- package/dist/memoryScan.d.ts +20 -0
- package/dist/memoryScan.js +106 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +141 -0
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.js +199 -0
- package/dist/recall.d.ts +11 -0
- package/dist/recall.js +138 -0
- package/package.json +15 -6
- package/src/index.ts +0 -291
- package/src/memory.ts +0 -272
- package/src/memoryScan.ts +0 -130
- package/src/paths.ts +0 -168
- package/src/prompt.ts +0 -222
- package/src/recall.ts +0 -163
package/src/index.ts
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
-
import { tool } from "@opencode-ai/plugin"
|
|
3
|
-
import { buildMemorySystemPrompt } from "./prompt.js"
|
|
4
|
-
import { recallRelevantMemories, formatRecalledMemories } from "./recall.js"
|
|
5
|
-
import {
|
|
6
|
-
saveMemory,
|
|
7
|
-
deleteMemory,
|
|
8
|
-
listMemories,
|
|
9
|
-
searchMemories,
|
|
10
|
-
readMemory,
|
|
11
|
-
MEMORY_TYPES,
|
|
12
|
-
} from "./memory.js"
|
|
13
|
-
import { getMemoryDir } from "./paths.js"
|
|
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
|
-
|
|
126
|
-
export const MemoryPlugin: Plugin = async ({ worktree }) => {
|
|
127
|
-
getMemoryDir(worktree)
|
|
128
|
-
|
|
129
|
-
return {
|
|
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
|
-
}
|
|
146
|
-
}
|
|
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 })
|
|
154
|
-
}
|
|
155
|
-
|
|
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
|
-
|
|
185
|
-
const recalledSection = formatRecalledMemories(recalled)
|
|
186
|
-
const memoryPrompt = buildMemorySystemPrompt(worktree, recalledSection, {
|
|
187
|
-
includeIndex: !ignoreMemoryContext,
|
|
188
|
-
})
|
|
189
|
-
output.system.push(memoryPrompt)
|
|
190
|
-
},
|
|
191
|
-
|
|
192
|
-
tool: {
|
|
193
|
-
memory_save: tool({
|
|
194
|
-
description:
|
|
195
|
-
"Save or update a memory for future conversations. " +
|
|
196
|
-
"Each memory is stored as a markdown file with frontmatter. " +
|
|
197
|
-
"Use this when the user explicitly asks you to remember something, " +
|
|
198
|
-
"or when you observe important information worth preserving across sessions " +
|
|
199
|
-
"(user preferences, feedback, project context, external references). " +
|
|
200
|
-
"Check existing memories first with memory_list or memory_search to avoid duplicates.",
|
|
201
|
-
args: {
|
|
202
|
-
file_name: tool.schema
|
|
203
|
-
.string()
|
|
204
|
-
.describe(
|
|
205
|
-
'File name for the memory (without .md extension). Use snake_case, e.g. "user_role", "feedback_testing_style", "project_auth_rewrite"',
|
|
206
|
-
),
|
|
207
|
-
name: tool.schema.string().describe("Human-readable name for this memory"),
|
|
208
|
-
description: tool.schema
|
|
209
|
-
.string()
|
|
210
|
-
.describe("One-line description — used to decide relevance in future conversations, so be specific"),
|
|
211
|
-
type: tool.schema
|
|
212
|
-
.enum(MEMORY_TYPES)
|
|
213
|
-
.describe(
|
|
214
|
-
"Memory type: user (about the person), feedback (guidance on approach), project (ongoing work context), reference (pointers to external systems)",
|
|
215
|
-
),
|
|
216
|
-
content: tool.schema
|
|
217
|
-
.string()
|
|
218
|
-
.describe(
|
|
219
|
-
"Memory content. For feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines",
|
|
220
|
-
),
|
|
221
|
-
},
|
|
222
|
-
async execute(args) {
|
|
223
|
-
const filePath = saveMemory(worktree, args.file_name, args.name, args.description, args.type, args.content)
|
|
224
|
-
return `Memory saved to ${filePath}`
|
|
225
|
-
},
|
|
226
|
-
}),
|
|
227
|
-
|
|
228
|
-
memory_delete: tool({
|
|
229
|
-
description: "Delete a memory that is outdated, wrong, or no longer relevant. Also removes it from the index.",
|
|
230
|
-
args: {
|
|
231
|
-
file_name: tool.schema.string().describe("File name of the memory to delete (with or without .md extension)"),
|
|
232
|
-
},
|
|
233
|
-
async execute(args) {
|
|
234
|
-
const deleted = deleteMemory(worktree, args.file_name)
|
|
235
|
-
return deleted ? `Memory "${args.file_name}" deleted.` : `Memory "${args.file_name}" not found.`
|
|
236
|
-
},
|
|
237
|
-
}),
|
|
238
|
-
|
|
239
|
-
memory_list: tool({
|
|
240
|
-
description:
|
|
241
|
-
"List all saved memories with their names, types, and descriptions. " +
|
|
242
|
-
"Use this to check what memories exist before saving a new one (to avoid duplicates) " +
|
|
243
|
-
"or when you need to recall what's been stored.",
|
|
244
|
-
args: {},
|
|
245
|
-
async execute() {
|
|
246
|
-
const entries = listMemories(worktree)
|
|
247
|
-
if (entries.length === 0) {
|
|
248
|
-
return "No memories saved yet."
|
|
249
|
-
}
|
|
250
|
-
const lines = entries.map(
|
|
251
|
-
(e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}`,
|
|
252
|
-
)
|
|
253
|
-
return `${entries.length} memories found:\n${lines.join("\n")}`
|
|
254
|
-
},
|
|
255
|
-
}),
|
|
256
|
-
|
|
257
|
-
memory_search: tool({
|
|
258
|
-
description:
|
|
259
|
-
"Search memories by keyword. Searches across names, descriptions, and content. " +
|
|
260
|
-
"Use this to find relevant memories before answering questions or when the user references past conversations.",
|
|
261
|
-
args: {
|
|
262
|
-
query: tool.schema.string().describe("Search query — searches across name, description, and content"),
|
|
263
|
-
},
|
|
264
|
-
async execute(args) {
|
|
265
|
-
const results = searchMemories(worktree, args.query)
|
|
266
|
-
if (results.length === 0) {
|
|
267
|
-
return `No memories matching "${args.query}".`
|
|
268
|
-
}
|
|
269
|
-
const lines = results.map(
|
|
270
|
-
(e) => `- **${e.name}** (${e.type}) [${e.fileName}]: ${e.description}\n Content: ${e.content.slice(0, 200)}${e.content.length > 200 ? "..." : ""}`,
|
|
271
|
-
)
|
|
272
|
-
return `${results.length} matches for "${args.query}":\n${lines.join("\n")}`
|
|
273
|
-
},
|
|
274
|
-
}),
|
|
275
|
-
|
|
276
|
-
memory_read: tool({
|
|
277
|
-
description: "Read the full content of a specific memory file.",
|
|
278
|
-
args: {
|
|
279
|
-
file_name: tool.schema.string().describe("File name of the memory to read (with or without .md extension)"),
|
|
280
|
-
},
|
|
281
|
-
async execute(args) {
|
|
282
|
-
const entry = readMemory(worktree, args.file_name)
|
|
283
|
-
if (!entry) {
|
|
284
|
-
return `Memory "${args.file_name}" not found.`
|
|
285
|
-
}
|
|
286
|
-
return `# ${entry.name}\n**Type:** ${entry.type}\n**Description:** ${entry.description}\n\n${entry.content}`
|
|
287
|
-
},
|
|
288
|
-
}),
|
|
289
|
-
},
|
|
290
|
-
}
|
|
291
|
-
}
|
package/src/memory.ts
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs"
|
|
2
|
-
import { join, basename } from "path"
|
|
3
|
-
import {
|
|
4
|
-
getMemoryDir,
|
|
5
|
-
getMemoryEntrypoint,
|
|
6
|
-
ENTRYPOINT_NAME,
|
|
7
|
-
validateMemoryFileName,
|
|
8
|
-
MAX_MEMORY_FILES,
|
|
9
|
-
MAX_MEMORY_FILE_BYTES,
|
|
10
|
-
MAX_ENTRYPOINT_LINES,
|
|
11
|
-
MAX_ENTRYPOINT_BYTES,
|
|
12
|
-
FRONTMATTER_MAX_LINES,
|
|
13
|
-
} from "./paths.js"
|
|
14
|
-
|
|
15
|
-
export const MEMORY_TYPES = ["user", "feedback", "project", "reference"] as const
|
|
16
|
-
export type MemoryType = (typeof MEMORY_TYPES)[number]
|
|
17
|
-
|
|
18
|
-
export type MemoryEntry = {
|
|
19
|
-
filePath: string
|
|
20
|
-
fileName: string
|
|
21
|
-
name: string
|
|
22
|
-
description: string
|
|
23
|
-
type: MemoryType
|
|
24
|
-
content: string
|
|
25
|
-
rawContent: string
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function parseFrontmatter(raw: string): { frontmatter: Record<string, string>; content: string } {
|
|
29
|
-
const trimmed = raw.trim()
|
|
30
|
-
if (!trimmed.startsWith("---")) {
|
|
31
|
-
return { frontmatter: {}, content: trimmed }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const lines = trimmed.split("\n")
|
|
35
|
-
let closingLineIdx = -1
|
|
36
|
-
for (let i = 1; i < Math.min(lines.length, FRONTMATTER_MAX_LINES); i++) {
|
|
37
|
-
if (lines[i].trimEnd() === "---") {
|
|
38
|
-
closingLineIdx = i
|
|
39
|
-
break
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if (closingLineIdx === -1) {
|
|
43
|
-
return { frontmatter: {}, content: trimmed }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const endIndex = lines.slice(0, closingLineIdx).join("\n").length + 1
|
|
47
|
-
|
|
48
|
-
const frontmatterBlock = trimmed.slice(3, endIndex).trim()
|
|
49
|
-
const content = trimmed.slice(endIndex + 3).trim()
|
|
50
|
-
|
|
51
|
-
const frontmatter: Record<string, string> = {}
|
|
52
|
-
for (const line of frontmatterBlock.split("\n")) {
|
|
53
|
-
const colonIdx = line.indexOf(":")
|
|
54
|
-
if (colonIdx === -1) continue
|
|
55
|
-
const key = line.slice(0, colonIdx).trim()
|
|
56
|
-
const value = line.slice(colonIdx + 1).trim()
|
|
57
|
-
if (key && value) {
|
|
58
|
-
frontmatter[key] = value
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return { frontmatter, content }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function buildFrontmatter(name: string, description: string, type: MemoryType): string {
|
|
66
|
-
return `---\nname: ${name}\ndescription: ${description}\ntype: ${type}\n---`
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function parseMemoryType(raw: string | undefined): MemoryType | undefined {
|
|
70
|
-
if (!raw) return undefined
|
|
71
|
-
return MEMORY_TYPES.find((t) => t === raw)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function listMemories(worktree: string): MemoryEntry[] {
|
|
75
|
-
const memDir = getMemoryDir(worktree)
|
|
76
|
-
const entries: MemoryEntry[] = []
|
|
77
|
-
|
|
78
|
-
let files: string[]
|
|
79
|
-
try {
|
|
80
|
-
files = readdirSync(memDir, { encoding: "utf-8" })
|
|
81
|
-
.filter((f) => f.endsWith(".md") && f !== ENTRYPOINT_NAME)
|
|
82
|
-
.sort()
|
|
83
|
-
.slice(0, MAX_MEMORY_FILES)
|
|
84
|
-
} catch {
|
|
85
|
-
return entries
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
for (const fileName of files) {
|
|
89
|
-
const filePath = join(memDir, fileName)
|
|
90
|
-
try {
|
|
91
|
-
const rawContent = readFileSync(filePath, "utf-8")
|
|
92
|
-
const { frontmatter, content } = parseFrontmatter(rawContent)
|
|
93
|
-
entries.push({
|
|
94
|
-
filePath,
|
|
95
|
-
fileName,
|
|
96
|
-
name: frontmatter.name ?? fileName.replace(/\.md$/, ""),
|
|
97
|
-
description: frontmatter.description ?? "",
|
|
98
|
-
type: parseMemoryType(frontmatter.type) ?? "user",
|
|
99
|
-
content,
|
|
100
|
-
rawContent,
|
|
101
|
-
})
|
|
102
|
-
} catch {
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return entries
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function readMemory(worktree: string, fileName: string): MemoryEntry | null {
|
|
111
|
-
const safeName = validateMemoryFileName(fileName)
|
|
112
|
-
const memDir = getMemoryDir(worktree)
|
|
113
|
-
const filePath = join(memDir, safeName)
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
const rawContent = readFileSync(filePath, "utf-8")
|
|
117
|
-
const { frontmatter, content } = parseFrontmatter(rawContent)
|
|
118
|
-
return {
|
|
119
|
-
filePath,
|
|
120
|
-
fileName: basename(filePath),
|
|
121
|
-
name: frontmatter.name ?? fileName.replace(/\.md$/, ""),
|
|
122
|
-
description: frontmatter.description ?? "",
|
|
123
|
-
type: parseMemoryType(frontmatter.type) ?? "user",
|
|
124
|
-
content,
|
|
125
|
-
rawContent,
|
|
126
|
-
}
|
|
127
|
-
} catch {
|
|
128
|
-
return null
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function saveMemory(
|
|
133
|
-
worktree: string,
|
|
134
|
-
fileName: string,
|
|
135
|
-
name: string,
|
|
136
|
-
description: string,
|
|
137
|
-
type: MemoryType,
|
|
138
|
-
content: string,
|
|
139
|
-
): string {
|
|
140
|
-
const safeName = validateMemoryFileName(fileName)
|
|
141
|
-
const memDir = getMemoryDir(worktree)
|
|
142
|
-
const filePath = join(memDir, safeName)
|
|
143
|
-
|
|
144
|
-
const fileContent = `${buildFrontmatter(name, description, type)}\n\n${content.trim()}\n`
|
|
145
|
-
if (Buffer.byteLength(fileContent, "utf-8") > MAX_MEMORY_FILE_BYTES) {
|
|
146
|
-
throw new Error(
|
|
147
|
-
`Memory file content exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`,
|
|
148
|
-
)
|
|
149
|
-
}
|
|
150
|
-
writeFileSync(filePath, fileContent, "utf-8")
|
|
151
|
-
|
|
152
|
-
updateIndex(worktree, safeName, name, description)
|
|
153
|
-
|
|
154
|
-
return filePath
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function deleteMemory(worktree: string, fileName: string): boolean {
|
|
158
|
-
const safeName = validateMemoryFileName(fileName)
|
|
159
|
-
const memDir = getMemoryDir(worktree)
|
|
160
|
-
const filePath = join(memDir, safeName)
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
unlinkSync(filePath)
|
|
164
|
-
removeFromIndex(worktree, safeName)
|
|
165
|
-
return true
|
|
166
|
-
} catch {
|
|
167
|
-
return false
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function searchMemories(worktree: string, query: string): MemoryEntry[] {
|
|
172
|
-
const all = listMemories(worktree)
|
|
173
|
-
const lowerQuery = query.toLowerCase()
|
|
174
|
-
|
|
175
|
-
return all.filter(
|
|
176
|
-
(entry) =>
|
|
177
|
-
entry.name.toLowerCase().includes(lowerQuery) ||
|
|
178
|
-
entry.description.toLowerCase().includes(lowerQuery) ||
|
|
179
|
-
entry.content.toLowerCase().includes(lowerQuery),
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function readIndex(worktree: string): string {
|
|
184
|
-
const entrypoint = getMemoryEntrypoint(worktree)
|
|
185
|
-
try {
|
|
186
|
-
return readFileSync(entrypoint, "utf-8")
|
|
187
|
-
} catch {
|
|
188
|
-
return ""
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function updateIndex(worktree: string, fileName: string, name: string, description: string): void {
|
|
193
|
-
const entrypoint = getMemoryEntrypoint(worktree)
|
|
194
|
-
const existing = readIndex(worktree)
|
|
195
|
-
const lines = existing.split("\n").filter((l) => l.trim())
|
|
196
|
-
|
|
197
|
-
const pointer = `- [${name}](${fileName}) — ${description}`
|
|
198
|
-
const existingIdx = lines.findIndex((l) => l.includes(`(${fileName})`))
|
|
199
|
-
|
|
200
|
-
if (existingIdx >= 0) {
|
|
201
|
-
lines[existingIdx] = pointer
|
|
202
|
-
} else {
|
|
203
|
-
lines.push(pointer)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
writeFileSync(entrypoint, lines.join("\n") + "\n", "utf-8")
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function removeFromIndex(worktree: string, fileName: string): void {
|
|
210
|
-
const entrypoint = getMemoryEntrypoint(worktree)
|
|
211
|
-
const existing = readIndex(worktree)
|
|
212
|
-
const lines = existing
|
|
213
|
-
.split("\n")
|
|
214
|
-
.filter((l) => l.trim() && !l.includes(`(${fileName})`))
|
|
215
|
-
|
|
216
|
-
writeFileSync(entrypoint, lines.length > 0 ? lines.join("\n") + "\n" : "", "utf-8")
|
|
217
|
-
}
|
|
218
|
-
|
|
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 {
|
|
235
|
-
const trimmed = raw.trim()
|
|
236
|
-
if (!trimmed) return { content: "", lineCount: 0, byteCount: 0, wasLineTruncated: false, wasByteTruncated: false }
|
|
237
|
-
|
|
238
|
-
const contentLines = trimmed.split("\n")
|
|
239
|
-
const lineCount = contentLines.length
|
|
240
|
-
const byteCount = trimmed.length
|
|
241
|
-
|
|
242
|
-
const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES
|
|
243
|
-
const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES
|
|
244
|
-
|
|
245
|
-
if (!wasLineTruncated && !wasByteTruncated) {
|
|
246
|
-
return { content: trimmed, lineCount, byteCount, wasLineTruncated, wasByteTruncated }
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
let truncated = wasLineTruncated
|
|
250
|
-
? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join("\n")
|
|
251
|
-
: trimmed
|
|
252
|
-
|
|
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)
|
|
256
|
-
}
|
|
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
|
-
|
|
265
|
-
return {
|
|
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,
|
|
271
|
-
}
|
|
272
|
-
}
|
package/src/memoryScan.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
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
|
-
}
|