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/README.md +20 -1
- package/bin/opencode-memory +435 -12
- package/package.json +1 -1
- package/src/index.ts +165 -16
- package/src/memory.ts +44 -15
- package/src/memoryScan.ts +130 -0
- package/src/paths.ts +6 -3
- package/src/prompt.ts +187 -112
- package/src/recall.ts +81 -44
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.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: "",
|
|
236
|
+
if (!trimmed) return { content: "", lineCount: 0, byteCount: 0, wasLineTruncated: false, wasByteTruncated: false }
|
|
220
237
|
|
|
221
|
-
const
|
|
222
|
-
const lineCount =
|
|
238
|
+
const contentLines = trimmed.split("\n")
|
|
239
|
+
const lineCount = contentLines.length
|
|
223
240
|
const byteCount = trimmed.length
|
|
224
241
|
|
|
225
|
-
const wasLineTruncated = lineCount >
|
|
226
|
-
const wasByteTruncated = byteCount >
|
|
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,
|
|
246
|
+
return { content: trimmed, lineCount, byteCount, wasLineTruncated, wasByteTruncated }
|
|
230
247
|
}
|
|
231
248
|
|
|
232
|
-
let truncated = wasLineTruncated
|
|
249
|
+
let truncated = wasLineTruncated
|
|
250
|
+
? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join("\n")
|
|
251
|
+
: trimmed
|
|
233
252
|
|
|
234
|
-
if (truncated.length >
|
|
235
|
-
const cutAt = truncated.lastIndexOf("\n",
|
|
236
|
-
truncated = truncated.slice(0, cutAt > 0 ? cutAt :
|
|
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 +
|
|
241
|
-
|
|
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
|
|
144
|
+
export function getProjectDir(worktree: string): string {
|
|
145
145
|
const canonicalRoot = findCanonicalGitRoot(worktree) ?? worktree
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|