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
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:18 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:18 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, 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 297 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  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}
297
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