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, 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 297 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 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
52 const slug = (await $.session.cwd()).replace(/[^A-Za-z0-9]/g, '-')
53
54 return `${config}/projects/${slug}/memory`
55}
56
57const memoryFiles = async ($: EngineInterface, dir: string): Promise<FsEntry[]> =>
58 (await $.fs.list(dir).catch(() => [])).filter(one => one.kind === 'file' && one.name.endsWith('.md'))
59
60const readMemory = async ($: EngineInterface, dir: string) =>
61 new Map(
62 await Promise.all(
63 (await memoryFiles($, dir)).map(async one => [one.name, await $.fs.read(`${dir}/${one.name}`)] as const),
64 ),
65 )
66
67// File name to content. Memory files are small, so the whole folder is read before and after the fork.
68type Snapshot = Map<string, string>
69
70const toLines = (text: string) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'))
71
72// Lines added and removed between two texts, counted from their longest common subsequence.
73export const lineDiff = (before: string, after: string) => {
74 const a = toLines(before)
75 const b = toLines(after)
76 let row = new Array<number>(b.length + 1).fill(0)
77 for (const line of a) {
78 const next = [0]
79 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))
80 row = next
81 }
82 const kept = row[b.length] ?? 0
83
84 return { added: b.length - kept, removed: a.length - kept }
85}
86
87export const diff = (before: Snapshot, after: Snapshot): Change[] => [
88 ...[...after].flatMap(([name, text]): Change[] => {
89 const old = before.get(name)
90 if (old === text) return []
91
92 return [{ name, kind: old === undefined ? 'added' : 'updated', ...lineDiff(old ?? '', text) }]
93 }),
94 ...[...before]
95 .filter(([name]) => !after.has(name))
96 .map(([name, text]): Change => ({ name, kind: 'deleted', ...lineDiff(text, '') })),
97]
98
99export const totals = (changes: Change[]) =>
100 changes.reduce((sum, one) => ({ added: sum.added + one.added, removed: sum.removed + one.removed }), { added: 0, removed: 0 })
101
102export const lastExchange = (messages: SessionMessage[]) => {
103 const start = messages.findLastIndex(m => m.role === 'user' && m.text.trim() !== '' && !m.toolResults?.length)
104 const lines = messages.slice(Math.max(0, start)).flatMap(m => [
105 ...(m.text.trim() ? [`${m.role.toUpperCase()}: ${m.text.trim()}`] : []),
106 ...m.toolUses.map(t => `TOOL: ${t.tool}`),
107 ])
108 const text = lines.join('\n')
109
110 return text.length > 12000 ? `${text.slice(0, 4000)}\n…\n${text.slice(-8000)}` : text
111}
112
113const setRun = ($: EngineInterface, id: string, patch: Partial<Run>) =>
114 update($, runs, list => list.map(run => (run.id === id ? { ...run, ...patch } : run)))
115
116let isBusy = false
117let isPending = false
118
119async function consider($: EngineInterface) {
120 if (isBusy) {
121 isPending = true
122 return
123 }
124 isBusy = true
125 try {
126 await write($)
127 } finally {
128 isBusy = false
129 if (isPending) {
130 isPending = false
131 void consider($).catch(() => undefined)
132 }
133 }
134}
135
136async function write($: EngineInterface) {
137 const id = crypto.randomUUID()
138 await update($, runs, list => [...list, { id, at: Date.now(), status: 'classifying', changes: [] } as Run].slice(-20))
139
140 const dir = await memoryDir($)
141 const index = await $.fs.read(`${dir}/MEMORY.md`).catch(() => '(empty)')
142 const exchange = lastExchange(await $.session.messages())
143 const verdict = await $.model.complete({
144 model: 'haiku',
145 system: JUDGE,
146 prompt: `<memory_index>\n${index}\n</memory_index>\n\n<turn>\n${exchange}\n</turn>`,
147 maxTokens: 60,
148 timeoutMs: 20000,
149 })
150 if (!verdict.isAnswered) return setRun($, id, { status: 'failed', reason: `haiku: ${verdict.reason}` })
151
152 const line = verdict.text.trim()
153 if (!line.startsWith('UPDATE')) return setRun($, id, { status: 'skipped' })
154
155 const reason = line.replace(/^UPDATE:?\s*/, '')
156 await setRun($, id, { status: 'forking', reason })
157 const before = await readMemory($, dir)
158 const reply = await $.model.fork({ prompt: writerPrompt(reason, before) })
159 if (!reply.isAnswered) return setRun($, id, { status: 'failed', reason: `fork: ${reply.reason}` })
160
161 let ops: ReturnType<typeof parseOps>
162 try {
163 ops = parseOps(reply.text)
164 } catch {
165 return setRun($, id, { status: 'failed', reason: 'fork did not answer with JSON' })
166 }
167 for (const one of ops.write) await $.fs.write(`${dir}/${one.name}`, one.content)
168 if (ops.delete.length > 0) await $.process.run(['rm', '-f', ...ops.delete.map(name => `${dir}/${name}`)])
169
170 await setRun($, id, { status: 'done', changes: diff(before, await readMemory($, dir)) })
171}
172
173export const register: Register = on => {
174 on('session.start', async ($, e, next) => {
175 await $.command.register({ name: 'memory-fork', description: 'Show what the memory fork changed' })
176 void $.ui.open({ id: PANE, title: 'Memory' })
177
178 return next(e)
179 })
180
181 on('command.run', { command: 'memory-fork' }, async $ => {
182 await $.ui.open({ id: PANE, title: 'Memory' })
183
184 return { text: 'Memory pane opened.' }
185 })
186
187 on('prompt.compose', async ($, e, next) => {
188 const { sections } = await next(e)
189 const dir = await memoryDir($)
190
191 return {
192 sections: [
193 ...sections,
194 {
195 id: 'memory-fork:handoff',
196 scope: 'session',
197 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.`,
198 },
199 ],
200 }
201 })
202
203 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
204 if (e.agentId !== undefined) return next(e)
205 const dir = await memoryDir($)
206
207 return e.file_path.startsWith(`${dir}/`)
208 ? { deny: 'memory-fork: memory is written by the background fork at the end of the turn, not by the main session.' }
209 : next(e)
210 }).catch(($, e, next) => next(e))
211
212 on('turn.complete', async ($, e, next) => {
213 const result = await next(e)
214 if (e.agentId === undefined && e.reason === 'answer') void consider($).catch(() => undefined)
215
216 return result
217 })
218
219 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
220 const latest = (await read($, runs)).findLast(run => run.status !== 'classifying' && run.status !== 'skipped')
221 if (e.props.hasSurvey || !latest || latest.id === (await read($, dismissed))) return next(e)
222
223 const { Box, Button, Text } = $.ui.resolve(e)
224 const files = latest.changes.map(change => change.name).join(', ')
225 const sum = totals(latest.changes)
226
227 return (
228 <Box>
229 <Text color={latest.status === 'failed' ? 'red' : undefined}>
230 {latest.status === 'forking' && `Memory: updating (${latest.reason}) `}
231 {latest.status === 'done' && 'Memory: '}
232 {latest.status === 'done' && latest.changes.length > 0 && <Text color="green">+{sum.added} </Text>}
233 {latest.status === 'done' && latest.changes.length > 0 && <Text color="red">−{sum.removed} </Text>}
234 {latest.status === 'done' && <Text dimColor>{files ? `· ${files} ` : 'no change '}</Text>}
235 {latest.status === 'failed' && `Memory: failed (${latest.reason}) `}
236 </Text>
237 <Button key="open" label="Open" onPress={() => $.ui.open({ id: PANE, title: 'Memory' })} />
238 <Text> </Text>
239 <Button key="hide" label="Hide" onPress={() => update($, dismissed, () => latest.id)} />
240 </Box>
241 )
242 })
243
244 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
245 const { Box, Text } = $.ui.resolve(e)
246 const list = await read($, runs)
247 const touched = new Set(list.flatMap(run => run.changes.filter(c => c.name !== 'MEMORY.md').map(c => c.name)))
248 const session = totals(list.flatMap(run => run.changes))
249 const room = Math.max(1, (e.viewport?.rows ?? 24) - 6)
250 const time = (at: number) => new Date(at).toTimeString().slice(0, 5)
251 const mark = { added: '+', updated: '~', deleted: '-' } as const
252 const colour = { added: 'green', updated: 'yellow', deleted: 'red' } as const
253
254 return (
255 <Box flexDirection="column">
256 <Box>
257 <Text bold>
258 {touched.size} {touched.size === 1 ? 'memory' : 'memories'} changed this session{' '}
259 </Text>
260 <Text color="green">+{session.added}</Text>
261 <Text> </Text>
262 <Text color="red">−{session.removed}</Text>
263 </Box>
264 {list.length === 0 && <Text dimColor>Waiting for the first turn to end.</Text>}
265 {[...list]
266 .reverse()
267 .filter(run => run.status !== 'skipped' || run === list[list.length - 1])
268 .slice(0, room)
269 .map(run => (
270 <Box flexDirection="column" marginTop={1}>
271 <Text dimColor={run.status === 'skipped'}>
272 {time(run.at)}{' '}
273 {run.status === 'classifying' && 'Haiku is checking the turn…'}
274 {run.status === 'skipped' && 'nothing to remember'}
275 {run.status === 'forking' && `fork writing: ${run.reason}`}
276 {run.status === 'done' &&
277 (run.changes.length === 0 ? 'fork changed nothing' : `${run.changes.length} file${run.changes.length === 1 ? '' : 's'} changed`)}
278 {run.status === 'failed' && `failed: ${run.reason}`}
279 </Text>
280 {run.changes.map(change => (
281 <Box>
282 <Text color={colour[change.kind]}>
283 {' '}
284 {mark[change.kind]} {change.name}{' '}
285 </Text>
286 <Text color="green">+{change.added}</Text>
287 <Text> </Text>
288 <Text color="red">−{change.removed}</Text>
289 </Box>
290 ))}
291 </Box>
292 ))}
293 </Box>
294 )
295 })
296}
297types/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