After each turn Haiku decides whether memory needs a change; a tool-less fork of the session drafts it and the mod writes it, with a band and a pane showing…

A Claude Code mod that keeps your auto-memory up to date in the background, so the main session never stops to write it.

After every turn:
MEMORY.md index and answers SKIP or UPDATE: <what to save>.$.model.fork replays the session's own transcript (served from the prompt cache) with the current memory files attached. Every tool is denied; it can only reply with JSON: {"write": [{"name", "content"}], "delete": [...]}.$.fs.write and deletions run as rm -f (on Windows, which has no rm, a PowerShell Remove-Item that gets the paths in an environment variable, never in the script), only for bare *.md names inside the project's memory folder. MEMORY.md is never deleted.A band above the prompt shows the run (Memory: updating …, then the lines added and removed and the files changed, as in Memory: +13 −13 · db-p99-under-10ms.md, MEMORY.md) with Open and Hide buttons. Open shows a pane listing every run this session, each file with its own +added −removed count, and the session total at the top. /memory-fork opens the same pane.
The main session is told that memory is handled, and its own Write/Edit calls into the memory folder are refused.
The first version spawned a forked agent to write the files. In auto mode every agent spawn goes to the permission classifier, and the classifier gives no verdict on a spawn the user never typed, so every run failed. A tool-less $.model.fork makes no tool calls, and the files are written by the mod's own code.
That also means the writes skip every permission prompt, in every mode. The file-name guard is the only limit on them. Read hooks/register.tsx before you install it.
/plugin install memory-fork --marketplace ray-amjad/claude-code-mods
Answer y to add the marketplace, then pick a scope.
claude --plugin-dir ./memory-fork
claude plugin validate .
claude plugin test .hooks/register.tsx 332 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, FsEntry, Register, SessionMessage } from 'claude-code'
3
4import type { Change, Run } from '../types'
5
6const PANE = 'memory-fork'
7const runs = atom({ plugin: 'memory-fork', key: 'runs' } as const, [])
8const dismissed = atom({ plugin: 'memory-fork', key: 'dismissed' } as const, null)
9
10const JUDGE = `You decide whether a coding assistant should update its persistent memory after one turn of conversation.
11
12Memory holds only facts that help FUTURE sessions and that the repo itself does not record:
13- user: who the user is, their role, expertise, preferences
14- feedback: a correction or a confirmed way of working the user gave
15- project: ongoing goals, constraints, decisions, dates not derivable from code or git
16- reference: pointers to external resources (URLs, dashboards, tickets)
17It also covers fixing or deleting a memory the turn proved wrong or stale.
18
19Say SKIP for routine work, facts that only matter to this conversation, code structure, or anything already in the index unchanged.
20
21Answer with exactly one line: "UPDATE: <what to save, under 20 words>" or "SKIP".`
22
23// The mod writes the files itself: a spawned agent needs a permission verdict that auto mode will not give a spawn nobody typed.
24const writerPrompt = (reason: string, files: Map<string, string>) => `[memory-fork] This message is from the memory-fork plugin, not the user. Do not use tools; answer in text only.
25
26A classifier flagged the latest turn of this conversation for memory: ${reason}
27
28Decide the change to the auto-memory, following the memory rules in your system prompt: one fact per file with frontmatter, update an existing file rather than duplicate, delete a memory the conversation proved wrong, keep MEMORY.md's one-line index in step. The current files:
29
30${[...files].map(([name, text]) => `<file name="${name}">\n${text}\n</file>`).join('\n\n') || '(none)'}
31
32Reply with only a JSON object: {"write": [{"name": "<file>.md", "content": "<whole new file>"}], "delete": ["<file>.md"]}. Bare file names, no paths. Include MEMORY.md whenever the index changes. If nothing is worth saving, reply {"write": [], "delete": []}.`
33
34const isMemoryFile = (name: unknown): name is string => typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]*\.md$/.test(name)
35
36export const parseOps = (text: string) => {
37 const json = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)) as {
38 write?: { name?: unknown; content?: unknown }[]
39 delete?: unknown[]
40 }
41
42 return {
43 write: (json.write ?? []).filter(
44 (one): one is { name: string; content: string } => isMemoryFile(one.name) && typeof one.content === 'string',
45 ),
46 delete: (json.delete ?? []).filter(isMemoryFile).filter(name => name !== 'MEMORY.md'),
47 }
48}
49
50const memoryDir = async ($: EngineInterface) => {
51 // A native Windows terminal sets USERPROFILE, not HOME
52 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
53 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
54 const slug = (await $.session.cwd()).replace(/[^A-Za-z0-9]/g, '-')
55
56 return `${config}/projects/${slug}/memory`
57}
58
59// $.fs has no remove, so a command deletes. A native Windows terminal has no
60// rm, and node is not promised there, so PowerShell does it, which every
61// Windows has. The paths go in an env var, never in the script, so nothing in
62// a path can run as code.
63export const removeCommand = (isWindows: boolean, paths: string[]): [string[], { env?: Record<string, string> }] =>
64 isWindows
65 ? [
66 [
67 'powershell',
68 '-NoProfile',
69 '-NonInteractive',
70 '-Command',
71 'foreach ($p in ($env:MEMORY_FORK_PATHS -split "`n")) { if ($p) { Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } }',
72 ],
73 { env: { MEMORY_FORK_PATHS: paths.join('\n') } },
74 ]
75 : [['rm', '-f', ...paths], {}]
76
77// C:\x, C:/x, or a share: \\server\x or //server/x
78const WINDOWS_PATH = /^([A-Za-z]:[\\/]|\\\\|\/\/)/
79
80// Whether path is inside dir, with / or \ as the separator. Windows paths are compared without case.
81export const isInside = (path: string, dir: string) => {
82 const windows = WINDOWS_PATH.test(dir) || WINDOWS_PATH.test(path)
83 const norm = (p: string) => (windows ? p.replace(/\\/g, '/').toLowerCase() : p)
84
85 return norm(path).startsWith(`${norm(dir)}/`)
86}
87
88const memoryFiles = async ($: EngineInterface, dir: string): Promise<FsEntry[]> =>
89 (await $.fs.list(dir).catch(() => [])).filter(one => one.kind === 'file' && one.name.endsWith('.md'))
90
91const readMemory = async ($: EngineInterface, dir: string) =>
92 new Map(
93 await Promise.all(
94 (await memoryFiles($, dir)).map(async one => [one.name, await $.fs.read(`${dir}/${one.name}`)] as const),
95 ),
96 )
97
98// File name to content. Memory files are small, so the whole folder is read before and after the fork.
99type Snapshot = Map<string, string>
100
101const toLines = (text: string) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'))
102
103// Lines added and removed between two texts, counted from their longest common subsequence.
104export const lineDiff = (before: string, after: string) => {
105 const a = toLines(before)
106 const b = toLines(after)
107 let row = new Array<number>(b.length + 1).fill(0)
108 for (const line of a) {
109 const next = [0]
110 for (let j = 0; j < b.length; j++) next.push(line === b[j] ? (row[j] ?? 0) + 1 : Math.max(row[j + 1] ?? 0, next[j] ?? 0))
111 row = next
112 }
113 const kept = row[b.length] ?? 0
114
115 return { added: b.length - kept, removed: a.length - kept }
116}
117
118export const diff = (before: Snapshot, after: Snapshot): Change[] => [
119 ...[...after].flatMap(([name, text]): Change[] => {
120 const old = before.get(name)
121 if (old === text) return []
122
123 return [{ name, kind: old === undefined ? 'added' : 'updated', ...lineDiff(old ?? '', text) }]
124 }),
125 ...[...before]
126 .filter(([name]) => !after.has(name))
127 .map(([name, text]): Change => ({ name, kind: 'deleted', ...lineDiff(text, '') })),
128]
129
130export const totals = (changes: Change[]) =>
131 changes.reduce((sum, one) => ({ added: sum.added + one.added, removed: sum.removed + one.removed }), { added: 0, removed: 0 })
132
133export const lastExchange = (messages: SessionMessage[]) => {
134 const start = messages.findLastIndex(m => m.role === 'user' && m.text.trim() !== '' && !m.toolResults?.length)
135 const lines = messages.slice(Math.max(0, start)).flatMap(m => [
136 ...(m.text.trim() ? [`${m.role.toUpperCase()}: ${m.text.trim()}`] : []),
137 ...m.toolUses.map(t => `TOOL: ${t.tool}`),
138 ])
139 const text = lines.join('\n')
140
141 return text.length > 12000 ? `${text.slice(0, 4000)}\n…\n${text.slice(-8000)}` : text
142}
143
144const setRun = ($: EngineInterface, id: string, patch: Partial<Run>) =>
145 update($, runs, list => list.map(run => (run.id === id ? { ...run, ...patch } : run)))
146
147let isBusy = false
148let isPending = false
149
150async function consider($: EngineInterface) {
151 if (isBusy) {
152 isPending = true
153 return
154 }
155 isBusy = true
156 try {
157 await write($)
158 } finally {
159 isBusy = false
160 if (isPending) {
161 isPending = false
162 void consider($).catch(() => undefined)
163 }
164 }
165}
166
167async function write($: EngineInterface) {
168 const id = crypto.randomUUID()
169 await update($, runs, list => [...list, { id, at: Date.now(), status: 'classifying', changes: [] } as Run].slice(-20))
170
171 const dir = await memoryDir($)
172 const index = await $.fs.read(`${dir}/MEMORY.md`).catch(() => '(empty)')
173 const exchange = lastExchange(await $.session.messages())
174 const verdict = await $.model.complete({
175 model: 'haiku',
176 system: JUDGE,
177 prompt: `<memory_index>\n${index}\n</memory_index>\n\n<turn>\n${exchange}\n</turn>`,
178 maxTokens: 60,
179 timeoutMs: 20000,
180 })
181 if (!verdict.isAnswered) return setRun($, id, { status: 'failed', reason: `haiku: ${verdict.reason}` })
182
183 const line = verdict.text.trim()
184 if (!line.startsWith('UPDATE')) return setRun($, id, { status: 'skipped' })
185
186 const reason = line.replace(/^UPDATE:?\s*/, '')
187 await setRun($, id, { status: 'forking', reason })
188 const before = await readMemory($, dir)
189 const reply = await $.model.fork({ prompt: writerPrompt(reason, before) })
190 if (!reply.isAnswered) return setRun($, id, { status: 'failed', reason: `fork: ${reply.reason}` })
191
192 let ops: ReturnType<typeof parseOps>
193 try {
194 ops = parseOps(reply.text)
195 } catch {
196 return setRun($, id, { status: 'failed', reason: 'fork did not answer with JSON' })
197 }
198 for (const one of ops.write) await $.fs.write(`${dir}/${one.name}`, one.content)
199 if (ops.delete.length > 0) {
200 const paths = ops.delete.map(name => `${dir}/${name}`)
201 const [argv, init] = removeCommand((await $.env.get('OS')) === 'Windows_NT', paths)
202 await $.process.run(argv, init)
203 }
204
205 await setRun($, id, { status: 'done', changes: diff(before, await readMemory($, dir)) })
206}
207
208export const register: Register = on => {
209 on('session.start', async ($, e, next) => {
210 await $.command.register({ name: 'memory-fork', description: 'Show what the memory fork changed' })
211 void $.ui.open({ id: PANE, title: 'Memory' })
212
213 return next(e)
214 })
215
216 on('command.run', { command: 'memory-fork' }, async $ => {
217 await $.ui.open({ id: PANE, title: 'Memory' })
218
219 return { text: 'Memory pane opened.' }
220 })
221
222 on('prompt.compose', async ($, e, next) => {
223 const { sections } = await next(e)
224 const dir = await memoryDir($)
225
226 return {
227 sections: [
228 ...sections,
229 {
230 id: 'memory-fork:handoff',
231 scope: 'session',
232 text: `# Memory writes are handled for you\nThe memory-fork plugin decides after every turn whether ${dir} needs a change and makes it in a background fork. Do not write, edit or delete files there yourself; if the user asks you to remember something, acknowledge it and it will be saved when the turn ends. A message starting "[memory-fork]" comes from that plugin, not the user: answer it in text only, without tools.`,
233 },
234 ],
235 }
236 })
237
238 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
239 if (e.agentId !== undefined) return next(e)
240 const dir = await memoryDir($)
241
242 return isInside(e.file_path, dir)
243 ? { deny: 'memory-fork: memory is written by the background fork at the end of the turn, not by the main session.' }
244 : next(e)
245 }).catch(($, e, next) => next(e))
246
247 on('turn.complete', async ($, e, next) => {
248 const result = await next(e)
249 if (e.agentId === undefined && e.reason === 'answer') void consider($).catch(() => undefined)
250
251 return result
252 })
253
254 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
255 const latest = (await read($, runs)).findLast(run => run.status !== 'classifying' && run.status !== 'skipped')
256 if (e.props.hasSurvey || !latest || latest.id === (await read($, dismissed))) return next(e)
257
258 const { Box, Button, Text } = $.ui.resolve(e)
259 const files = latest.changes.map(change => change.name).join(', ')
260 const sum = totals(latest.changes)
261
262 return (
263 <Box>
264 <Text color={latest.status === 'failed' ? 'red' : undefined}>
265 {latest.status === 'forking' && `Memory: updating (${latest.reason}) `}
266 {latest.status === 'done' && 'Memory: '}
267 {latest.status === 'done' && latest.changes.length > 0 && <Text color="green">+{sum.added} </Text>}
268 {latest.status === 'done' && latest.changes.length > 0 && <Text color="red">−{sum.removed} </Text>}
269 {latest.status === 'done' && <Text dimColor>{files ? `· ${files} ` : 'no change '}</Text>}
270 {latest.status === 'failed' && `Memory: failed (${latest.reason}) `}
271 </Text>
272 <Button key="open" label="Open" onPress={() => $.ui.open({ id: PANE, title: 'Memory' })} />
273 <Text> </Text>
274 <Button key="hide" label="Hide" onPress={() => update($, dismissed, () => latest.id)} />
275 </Box>
276 )
277 })
278
279 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
280 const { Box, Text } = $.ui.resolve(e)
281 const list = await read($, runs)
282 const touched = new Set(list.flatMap(run => run.changes.filter(c => c.name !== 'MEMORY.md').map(c => c.name)))
283 const session = totals(list.flatMap(run => run.changes))
284 const room = Math.max(1, (e.viewport?.rows ?? 24) - 6)
285 const time = (at: number) => new Date(at).toTimeString().slice(0, 5)
286 const mark = { added: '+', updated: '~', deleted: '-' } as const
287 const colour = { added: 'green', updated: 'yellow', deleted: 'red' } as const
288
289 return (
290 <Box flexDirection="column">
291 <Box>
292 <Text bold>
293 {touched.size} {touched.size === 1 ? 'memory' : 'memories'} changed this session{' '}
294 </Text>
295 <Text color="green">+{session.added}</Text>
296 <Text> </Text>
297 <Text color="red">−{session.removed}</Text>
298 </Box>
299 {list.length === 0 && <Text dimColor>Waiting for the first turn to end.</Text>}
300 {[...list]
301 .reverse()
302 .filter(run => run.status !== 'skipped' || run === list[list.length - 1])
303 .slice(0, room)
304 .map(run => (
305 <Box flexDirection="column" marginTop={1}>
306 <Text dimColor={run.status === 'skipped'}>
307 {time(run.at)}{' '}
308 {run.status === 'classifying' && 'Haiku is checking the turn…'}
309 {run.status === 'skipped' && 'nothing to remember'}
310 {run.status === 'forking' && `fork writing: ${run.reason}`}
311 {run.status === 'done' &&
312 (run.changes.length === 0 ? 'fork changed nothing' : `${run.changes.length} file${run.changes.length === 1 ? '' : 's'} changed`)}
313 {run.status === 'failed' && `failed: ${run.reason}`}
314 </Text>
315 {run.changes.map(change => (
316 <Box>
317 <Text color={colour[change.kind]}>
318 {' '}
319 {mark[change.kind]} {change.name}{' '}
320 </Text>
321 <Text color="green">+{change.added}</Text>
322 <Text> </Text>
323 <Text color="red">−{change.removed}</Text>
324 </Box>
325 ))}
326 </Box>
327 ))}
328 </Box>
329 )
330 })
331}
332types/index.d.ts 16 lines1export type Change = { name: string; kind: 'added' | 'updated' | 'deleted'; added: number; removed: number }
2
3export type Run = {
4 id: string
5 at: number
6 status: 'classifying' | 'skipped' | 'forking' | 'done' | 'failed'
7 reason?: string
8 changes: Change[]
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'memory-fork': { runs: Run[]; dismissed: string | null }
14 }
15}
16