SLOPSHOPPER

mental-model

The agent's working model of the user, shown above the prompt, and a nudge to rewrite it before a string of writes.

newbandguardprompttool
v0.8.2MITupdated 2026-10-13esinecan/mental-model-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mental-model
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 h: − Claude's model of you u: U Claude has not written down what you want yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
h: − Claude's model of you u: U Claude has not written down what you want yet.
README

mental-model

A Claude Code mod that makes Claude write down what it thinks you want, and shows that note above your prompt.

When an agent misreads a request, you usually find out after it has done the work. This mod moves that moment to the start. Claude keeps a short note with two fields: what you want, in your own words, and what you know and what you are missing. The note sits above the prompt box, so a wrong reading is on screen before the first file changes. You correct it the ordinary way, by telling Claude.

Nothing in the mod blocks a tool call.

The note

Claude writes the note with one tool:

mcp__mental-model__note(want, in_their_head)

Claude rewrites it when its understanding changes, not on every message. The tool result tells Claude which field changed and what it said before, so the model sees its own drift. The last twelve versions are kept as history.

The note and its last change also ride beside each of your messages, so Claude reads them where it reads your request. They never go into the system prompt. A system prompt that changes in the middle of a session misses the prompt cache for the whole conversation after it, and the note changes often.

× Remove deletes the current note. The versions before it stay, and Claude's next note follows the last one.

The note and its history are saved on disk under the session's id. When you restart Claude Code or resume the session, the note comes back. A new session, or a /clear, starts with no note. The mod keeps the 50 most recent sessions.

What Claude is told, and when

The mod adds a line for the model at four moments. The note line shows Claude its current note. The other three ask Claude to write or update the note. None of them blocks anything, and subagents never get them.

Attached toFires whenStarts with
Noteyour messagea note existsYour working model of the user (mental-model, note vN):, then the note and its last change
Reminderyour messageno note existsnothing on your first message, IMPORTANT: from your second message on
Update reminderyour messageyou pressed U (Update on next prompt), until Claude rewrites the notenothing
Nudgethe result of Claude's first write after your messageClaude writes something before it rewrites the noteTHIS IS VERY IMPORTANT: when no note exists, nothing when a note exists

Both reminders carry the same text:

Write down/update your understanding of user's intent, knowledge and gaps using the `mcp__mental-model__note` tool. This will help you tailor your effort to user's needs, and help the user to catch drift early on.

The nudge tells Claude its note is older than your last message, or that it has no note yet. The write it rides on has already run. A write is any tool call that Claude Code did not find read-only by the tool's own check. Creating, editing and deleting files all count, and reading or searching never does. Only the first write after each message carries the nudge, and a note written before that write means no nudge at all.

The band

The band is the panel above the prompt.

−  Claude's model of you  v3 · changed: intent · 2 min ago                    ≡  U  ×  ∅
Want        merge the branch and prepare the repo for release
────────────────────────────────────────────────────────────
Knows       knows the tests pass, has not picked a license yet

The header row shows the title, the note's version, which field changed last and how long ago. A thin line separates the Want row from the Knows row. The − Hide button sits at the top left. The other buttons sit on the right. Each button shows its name under the pointer:

ButtonKeyDoes
− Hidehfolds the band into one line with a + Show button (m)
≡ Historysopens the older versions under the note, newest first
U Update on next promptuturns on the Update reminder, and shows ✓ until Claude rewrites the note
× Removeddeletes the current note; the versions before it stay
∅ Remove allaasks first: a row under the header names what goes, with Remove all (y) and Keep (n). Remove all deletes the note and its history in this session, with no undo

The keys work while the band holds the keyboard: after ctrl+x tab, or after a click on the band. Typing in the prompt never presses them. An armed Remove all disarms when you press Keep, ∅ again or any other band button, when you send a message, or when Claude writes a new note.

Install

Type this at the prompt of a Claude Code terminal session:

/plugin install mental-model --marketplace esinecan/mental-model-mod

Answer y to add the marketplace, then pick a scope. The user scope loads the mod in every session you start after, including sessions the desktop app runs on the same machine.

To run a local checkout instead, start Claude Code with claude --plugin-dir <folder>, or add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. A running session keeps the version it loaded, and a change takes effect in the next session.

Develop

claude plugin validate . checks the manifest and lists the hooks. claude plugin test . runs hooks/register.test.ts. The tests cover:

  • the nudge: one per turn, only on a write, never after a fresh note
  • the reminder and its IMPORTANT: prefix, and Update on next prompt
  • the note beside each message, and a system prompt that a note never changes
  • the note after a rewrite, a Remove and a Remove all
  • saving and loading per session
  • the band on the terminal and desktop surfaces: each button, the Remove all confirmation, and each way it disarms

claude plugin test is in Claude Code 2.1.295. Claude Code 2.1.258 does not have it. Run claude --version to see which one you have.

tsc -p . type-checks the mod and the tests. Its tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes when it loads the mod from this folder.

The code is one hooks module, hooks/register.tsx, with its state types in types/index.d.ts.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 310 lines
1import { atom, derive, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Change, Note, Origin } from '../types'
5
6const PLUGIN = 'mental-model'
7const NOTE_TOOL = `mcp__${PLUGIN}__note`
8const HISTORY_CAP = 12
9const SESSION_CAP = 50
10
11const note = atom({ plugin: 'mental-model', key: 'note' } as const, null)
12const history = atom({ plugin: 'mental-model', key: 'history' } as const, [])
13const settled = atom({ plugin: 'mental-model', key: 'settled' } as const, false)
14const isHidden = atom({ plugin: 'mental-model', key: 'isHidden' } as const, false)
15const showHistory = atom({ plugin: 'mental-model', key: 'showHistory' } as const, false)
16const remind = atom({ plugin: 'mental-model', key: 'remind' } as const, false)
17const isRemoveAllArmed = atom({ plugin: 'mental-model', key: 'isRemoveAllArmed' } as const, false)
18const prompts = atom({ plugin: 'mental-model', key: 'prompts' } as const, 0)
19const loadedFor = atom({ plugin: 'mental-model', key: 'loadedFor' } as const, null)
20
21const origin = derive([note, history], (current, past) => (current ? originOf(past, current) : null))
22
23export const REMINDER =
24  `Write down/update your understanding of user's intent, knowledge and gaps using the \`${NOTE_TOOL}\` tool. ` +
25  "This will help you tailor your effort to user's needs, and help the user to catch drift early on."
26
27export const IMPORTANT = 'IMPORTANT: '
28
29export const NUDGE =
30  `${PLUGIN}: your note of the user predates the user's last message. ` +
31  `If what the user wants, knows or is missing has changed, rewrite it with ${NOTE_TOOL} before continuing this string of writes.`
32
33export const NUDGE_EMPTY =
34  `THIS IS VERY IMPORTANT: ${PLUGIN}: you have not written a note of the user yet. ` +
35  `Write it with ${NOTE_TOOL} before continuing this string of writes.`
36
37const entryKey = (id: string) => `session/${id}`
38
39async function save($: EngineInterface) {
40  const id = await $.session.id()
41  const [current, past] = await Promise.all([read($, note), read($, history)])
42  await $.store.set(entryKey(id), { note: current, history: past })
43  const order = ((await $.store.get('order')) as string[] | undefined) ?? []
44  const kept = [...order.filter(k => k !== id), id]
45  for (const old of kept.slice(0, Math.max(0, kept.length - SESSION_CAP))) await $.store.delete(entryKey(old))
46  await $.store.set('order', kept.slice(-SESSION_CAP))
47}
48
49async function load($: EngineInterface) {
50  const id = await $.session.id()
51  if ((await read($, loadedFor)) === id) return
52  const saved = (await $.store.get(entryKey(id))) as { note: Note | null; history: Note[] } | undefined
53  await Promise.all([
54    update($, loadedFor, () => id),
55    update($, note, () => saved?.note ?? null),
56    update($, history, () => saved?.history ?? []),
57    update($, prompts, () => 0),
58    update($, settled, () => false),
59    update($, remind, () => false),
60    update($, isRemoveAllArmed, () => false),
61  ])
62}
63
64const nextVersion = (current: Note | null, past: Note[]) =>
65  Math.max(current?.version ?? 0, ...past.map(n => n.version)) + 1
66
67export function diffNotes(previous: Note | null, next: Pick<Note, 'want' | 'inTheirHead'>): Change[] {
68  if (!previous) return []
69  const changes: Change[] = []
70  if (previous.want !== next.want) changes.push({ field: 'want', was: previous.want, now: next.want })
71  if (previous.inTheirHead !== next.inTheirHead) changes.push({ field: 'inTheirHead', was: previous.inTheirHead, now: next.inTheirHead })
72  return changes
73}
74
75const label = (field: Change['field']) => (field === 'want' ? 'intent' : 'knowledge')
76
77export function describeChanges(changes: Change[]): string {
78  if (!changes.length) return 'unchanged'
79  return changes.map(c => `${label(c.field)}: was «${c.was}» now «${c.now}»`).join('; ')
80}
81
82export function originOf(past: Note[], current: Note | null = null): Origin {
83  const previous = past[past.length - 1]
84  if (!previous) return { kind: 'first' }
85  return { kind: 'changed', previous, changes: current ? diffNotes(previous, current) : [] }
86}
87
88function originForModel(o: Origin): string {
89  if (o.kind === 'changed') return `Changed since v${o.previous.version}: ${describeChanges(o.changes)}.`
90  return 'First note.'
91}
92
93function originForBand(o: Origin): string {
94  if (o.kind === 'changed') return ` · changed: ${o.changes.map(c => label(c.field)).join(', ')}`
95  return ''
96}
97
98function noteForModel(current: Note, o: Origin): string {
99  const lines = [
100    `Your working model of the user (${PLUGIN}, note v${current.version}):`,
101    `Intent: ${current.want}`,
102    `Knowledge and gaps: ${current.inTheirHead}`,
103  ]
104  if (o.kind === 'changed') lines.push(originForModel(o))
105  return lines.join('\n')
106}
107
108function ago(now: number, at: number): string {
109  const s = Math.max(0, Math.round((now - at) / 1000))
110  if (s < 60) return `${s}s ago`
111  const m = Math.round(s / 60)
112  if (m < 60) return `${m} min ago`
113  return `${Math.round(m / 60)} h ago`
114}
115
116const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
117
118export const register: Register = on => {
119  on('session.start', async ($, e, next) => {
120    const result = await next(e)
121    await load($)
122    await $.tool.register({
123      name: 'note',
124      description:
125        'Your working model of the user, shown to the user above the prompt. ' +
126        'Rewrite it when your understanding of the user changes, before you act on the new understanding.',
127      inputSchema: {
128        type: 'object',
129        properties: {
130          want: { type: 'string', description: "The outcome the user wants, in the user's own words." },
131          in_their_head: {
132            type: 'string',
133            description: 'What the user knows and believes about the current state, and what the user is missing.',
134          },
135        },
136        required: ['want', 'in_their_head'],
137      },
138    })
139    return result
140  })
141
142  on('tool.call', { tool: NOTE_TOOL }, async ($, e) => {
143    const args = e as unknown as Record<string, unknown>
144    const want = String(args.want ?? '').trim()
145    const inTheirHead = String(args.in_their_head ?? '').trim()
146    if (!want || !inTheirHead) return { deny: `${NOTE_TOOL}: want and in_their_head are both required.` }
147    const [previous, past] = await Promise.all([read($, note), read($, history)])
148    await Promise.all([
149      update($, settled, () => true),
150      update($, remind, () => false),
151      update($, isRemoveAllArmed, () => false),
152    ])
153    if (previous && !diffNotes(previous, { want, inTheirHead }).length) {
154      return { result: `Note unchanged (v${previous.version}).` }
155    }
156    const entry: Note = { want, inTheirHead, version: nextVersion(previous, past), at: await $.clock.now() }
157    if (previous) await update($, history, list => [...list, previous].slice(-HISTORY_CAP))
158    await update($, note, () => entry)
159    await save($)
160    return { result: `Note v${entry.version} written. ${originForModel(originOf(await read($, history), entry))}` }
161  })
162
163  on('tool.call', async ($, e, next) => {
164    const inMainLoop = (e as { agentId?: string }).agentId === undefined
165    if (!inMainLoop || e.tool === NOTE_TOOL || (await read($, settled))) return next(e)
166    const answer = await next(e)
167    if (answer.deny !== undefined || answer.isError || answer.isReadOnly) return answer
168    await update($, settled, () => true)
169    const text = (await read($, note)) ? NUDGE : NUDGE_EMPTY
170    return { ...answer, context: [...(answer.context ?? []), text] }
171  })
172
173  on('prompt.submit', async ($, e, next) => {
174    const fromUser = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
175    if (!fromUser) return next(e)
176    await load($)
177    await Promise.all([
178      update($, settled, () => false),
179      update($, prompts, n => n + 1),
180      update($, isRemoveAllArmed, () => false),
181    ])
182    const [current, o, reminded, count] = await Promise.all([read($, note), read($, origin), read($, remind), read($, prompts)])
183    const lines: string[] = []
184    if (current && o) lines.push(noteForModel(current, o))
185    if (!current || reminded) lines.push(!current && count >= 2 ? IMPORTANT + REMINDER : REMINDER)
186    return next({ ...e, context: [...(e.context ?? []), ...lines] })
187  })
188
189  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
190    if (e.props.hasSurvey) return next(e)
191    const { Box, Button, Text } = $.ui.resolve(e)
192    const [current, past, o, hidden, open, reminded, armed, now] = await Promise.all([
193      read($, note), read($, history), read($, origin), read($, isHidden),
194      read($, showHistory), read($, remind), read($, isRemoveAllArmed), $.clock.now(),
195    ])
196    const stamp = current && o ? `v${current.version}${originForBand(o)}` : ''
197
198    const disarm = () => update($, isRemoveAllArmed, () => false)
199    const andDisarm = (act: () => unknown) => async () => {
200      await disarm()
201      await act()
202    }
203
204    if (hidden) {
205      return (
206        <Box>
207          <Text dimColor>{`Model hidden${stamp ? ` · ${stamp}` : ''}  `}</Text>
208          <Button key="show" label="+ Show" hotkey="m" plain onPress={() => update($, isHidden, () => false)} />
209        </Box>
210      )
211    }
212
213    const LABEL = 12
214    const row = (key: string, head: RenderElement, body: RenderElement) => (
215      <Box key={key}>
216        <Box width={LABEL} flexShrink={0}>{head}</Box>
217        <Box flexGrow={1}>{body}</Box>
218      </Box>
219    )
220
221    const glyph = (key: string, hotkey: string, icon: string, word: string, onPress: () => void, primary = false) => (
222      <Box key={`${key}-hover`} marginLeft={key === 'hide' ? 0 : 1} marginRight={key === 'hide' ? 2 : 0}>
223        <Button key={key} label={icon} hotkey={hotkey} plain variant={primary ? 'primary' : undefined} onPress={onPress} />
224        <Box position="absolute" top={1} left={0} display="none" hover={{ display: 'flex' }}>
225          <Text backgroundColor="subtle" color="inverseText">{` ${word} `}</Text>
226        </Box>
227      </Box>
228    )
229
230    const removeNote = async () => {
231      await update($, note, () => null)
232      await save($)
233    }
234    const removeAll = async () => {
235      await Promise.all([
236        update($, note, () => null),
237        update($, history, () => []),
238        update($, showHistory, () => false),
239        disarm(),
240      ])
241      await save($)
242    }
243
244    const header = (
245      <Box justifyContent="space-between">
246        <Box>
247          {glyph('hide', 'h', '−', 'Hide', andDisarm(() => update($, isHidden, () => true)))}
248          <Text bold>Claude's model of you</Text>
249          {current ? <Text dimColor>{`  ${stamp} · ${ago(now, current.at)}`}</Text> : null}
250        </Box>
251        <Box>
252          {past.length
253            ? glyph('history', 's', '≡', open ? 'Hide history' : `History (${past.length})`, andDisarm(() => update($, showHistory, v => !v)), open)
254            : null}
255          {glyph('remind', 'u', reminded ? '✓' : 'U', reminded ? 'Updates on next prompt' : 'Update on next prompt', andDisarm(() => update($, remind, () => true)), reminded)}
256          {current ? glyph('remove', 'd', '×', 'Remove', andDisarm(removeNote)) : null}
257          {current || past.length
258            ? glyph('removeAll', 'a', '∅', armed ? 'Cancel Remove all' : 'Remove all, history too', () => update($, isRemoveAllArmed, v => !v), armed)
259            : null}
260        </Box>
261      </Box>
262    )
263
264    const older = plural(past.length, 'older version')
265    const question = current ? (past.length ? `Remove the note and ${older}?` : 'Remove the note?') : `Remove all ${older}?`
266    const confirm = armed && (current || past.length) ? (
267      <Box key="confirm">
268        <Text>{`${question} There is no undo.  `}</Text>
269        <Button key="confirmRemoveAll" label="Remove all" hotkey="y" variant="primary" onPress={removeAll} />
270        <Box marginLeft={1}>
271          <Button key="cancelRemoveAll" label="Keep" hotkey="n" onPress={disarm} />
272        </Box>
273      </Box>
274    ) : null
275
276    if (!current) {
277      return (
278        <Box flexDirection="column">
279          {header}
280          {confirm}
281          <Text dimColor wrap="wrap">Claude has not written down what you want yet.</Text>
282        </Box>
283      )
284    }
285
286    return (
287      <Box flexDirection="column">
288        {header}
289        {confirm}
290        {row('want', <Text bold>Want</Text>, <Text wrap="wrap">{current.want}</Text>)}
291        <Text key="separator" dimColor wrap="truncate">{'─'.repeat(Math.max(8, e.props.bodyColumns ?? 40))}</Text>
292        {row('knows', <Text bold>Knows</Text>, <Text wrap="wrap">{current.inTheirHead}</Text>)}
293        {open ? <Text dimColor>History, newest first</Text> : null}
294        {open
295          ? past
296              .slice()
297              .reverse()
298              .map(n =>
299                row(
300                  `v${n.version}`,
301                  <Text dimColor>{`v${n.version} · ${ago(now, n.at)}`}</Text>,
302                  <Text dimColor wrap="wrap">{`${n.want} — ${n.inTheirHead}`}</Text>,
303                ),
304              )
305          : null}
306      </Box>
307    )
308  })
309}
310
types/index.d.ts 34 lines
1
2export type Note = {
3  want: string
4  inTheirHead: string
5  version: number
6  at: number
7}
8
9export type Change = {
10  field: 'want' | 'inTheirHead'
11  was: string
12  now: string
13}
14
15export type Origin =
16  | { kind: 'first' }
17  | { kind: 'changed'; previous: Note; changes: Change[] }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'mental-model': {
22      note: Note | null
23      history: Note[]
24      settled: boolean
25      isHidden: boolean
26      showHistory: boolean
27      remind: boolean
28      isRemoveAllArmed: boolean
29      prompts: number
30      loadedFor: string | null
31    }
32  }
33}
34