SLOPSHOPPER

memory-fork

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…

newpanebandguardcommandprompt
★ 67v0.3.1MITupdated 2026-10-15ray-amjad/claude-code-mods/plugins/memory-fork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memory-fork
│ ┃ Memory ✕ › fix the failing auth test and add an audit log call │ ┃ 0 memories changed this session +0 −0 │ ┃ ⏺ Read(src/auth.ts) │ ┃ 14:15 nothing to remember ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /memory-fork │ ⎿ memory-fork: Memory pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Memory
0 memories changed this session +0 −0 14:15 nothing to remember
README

memory-fork

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

memory-fork in a session: two "remember" turns, the Memory bar above the prompt with the lines added and removed and the files changed, with Open and Hide buttons, the Memory pane showing each run's added, updated and deleted files with their line counts, and the four steps from turn end to files on disk

After every turn:

  1. Haiku judges the turn. It reads the last exchange and your MEMORY.md index and answers SKIP or UPDATE: <what to save>.
  2. A fork drafts the change. $.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": [...]}.
  3. The mod writes it. The files are written with $.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.

Why a model fork, not a subagent

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.

Install

/plugin install memory-fork --marketplace ray-amjad/claude-code-mods

Answer y to add the marketplace, then pick a scope.

Develop

claude --plugin-dir ./memory-fork
claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 332 lines
1import { 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}
332
types/index.d.ts 16 lines
1export 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