SLOPSHOPPER

voice-notes

voice-brief notes pane: captured dictations, the five-field prompt, place, accept and hand off

newpanebandcommandtoaststatus
v0.2.0no licenseupdated 2026-10-25igouss/voice-brief/plugins/voice-notes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · voice-notes
│ ┃ voice-notes ✕ › fix the failing auth test and add an audit log call │ ┃ [ Minimize ] [ Maximize ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Notes (0) /work/app ⎿ Read 6 lines │ ┃ Not capturing: this session does not go ⏺ Update(src/auth.ts) │ ┃ through the voice proxy. Run /vb-setup, then ⎿ Added 2 lines, removed 1 line │ ┃ restart Claude Code. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Place ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · voice-notes
[ Minimize ] [ Maximize ] Notes (0) /work/app Not capturing: this session does not go through the voice proxy. Run /vb-setup, then restart Claude Code. [ Place ]
README

voice-notes

A Claude Code plugin: dictate an idea with /voice, and get a five-field task prompt (Weakest part, Force, Smallest move, Preserves, Would reverse it). Every claim is tagged said with your exact words, or added with a reason. A field you left empty comes back as a question.

Part of voice-brief; the design and the security notes are there.

Install

/plugin install voice-notes --marketplace igouss/voice-brief

Then, once per project, in Claude Code at the project's root:

/vb-setup

and restart Claude Code there.

/vb-setup needs Linux with systemd user services and mitmproxy (uv tool install mitmproxy). It does three things:

  • installs vb-voice-proxy.service, a local mitmproxy on 127.0.0.1:8897 that decrypts only api.anthropic.com and saves Claude Code's voice streams;
  • copies the proxy addon to ~/.local/share/voice-brief/;
  • writes HTTPS_PROXY and NODE_EXTRA_CA_CERTS into the project's .claude/settings.local.json.

The proxy decrypts every request a proxied session makes to Anthropic, OAuth token included. It writes only voice streams to disk.

Use

/voice and dictate. Each dictation becomes a note in the Voice notes pane as soon as it is transcribed; Enter only decides whether the agent also gets it.

Tap mode ("voice": { "mode": "tap" } in the project's .claude/settings.local.json): Space starts recording and Space again stops it, and Claude Code then submits the transcript by itself. The plugin drops that submission before it reaches the agent ("Kept as voice note tN; not sent to the agent") and clears the prompt box. Only a prompt that is exactly a dictation this session just captured is dropped; anything typed is sent. Tap mode is what lets the desktop window start and stop recording with one key each.

CommandDoes
/vb, /vb-min, /vb-maxopen, minimize to one line above the prompt, maximize
/vb-placeplace every live note into the five fields
/vb-accept, /vb-rejectaccept or reject the proposal
/vb-handoffcopy the accepted prompt to the clipboard and write prompt.md
/vb-delete tN, /vb-fix tN <text>drop a note, or correct its transcript
/vb-setupinstall the proxy and this project's settings

Placement is one call through Claude Code's own model API (claude-opus-5-5, effort medium), with only the plugin's system prompt: no CLAUDE.md, tools or history. A validator in the plugin rejects any reply whose quoted words are not in your notes.

Data, per Git project: ~/.local/share/voice-brief/projects/<git root with / as ->/.

Checks

claude plugin validate plugins/voice-notes
claude plugin test plugins/voice-notes
(cd plugins/voice-notes/setup && ~/.local/share/uv/tools/mitmproxy/bin/python -m unittest test_vb_mitm)

The plugin's ThreadEvent, ThreadLine, Claim and Field types at the top of types/index.d.ts are generated from desktop/thread/src/wire.rs; cargo test --workspace in desktop/ fails when they drift.

Source 3 files
hooks/register.tsx 414 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Claim, Field, Note, PaneSize, Proposal, ThreadEvent, ThreadLine } from '../types'
5import { FIELDS as KEYS, TITLES, validate } from './contract'
6
7const PANE = 'voice-notes'
8const FIELDS: [Field, string][] = KEYS.map(k => [k, TITLES[k]])
9
10const notes = atom({ plugin: 'voice-notes', key: 'notes' } as const, [] as Note[], { shape: 'notes-1' })
11const proposal = atom({ plugin: 'voice-notes', key: 'proposal' } as const, null as Proposal | null, { shape: 'proposal-1' })
12const accepted = atom({ plugin: 'voice-notes', key: 'accepted' } as const, null as Proposal | null, { shape: 'proposal-1' })
13const rev = atom({ plugin: 'voice-notes', key: 'rev' } as const, 0)
14const busy = atom({ plugin: 'voice-notes', key: 'busy' } as const, null as string | null)
15const message = atom({ plugin: 'voice-notes', key: 'message' } as const, '')
16const latest = atom({ plugin: 'voice-notes', key: 'latest' } as const, null as string | null)
17const size = atom({ plugin: 'voice-notes', key: 'size' } as const, 'normal' as PaneSize)
18
19const COLUMNS: Record<PaneSize, number> = { min: 0, normal: 56, max: 400 }
20const PROXY_URL = 'http://127.0.0.1:8897'
21const MODEL = 'claude-opus-5-5'
22const EFFORT = 'medium'
23const DICTATION_WAIT_STEPS = 12
24
25type IndexRow = { name: string; start: number; pid?: number | null; final: string; wav: string; closed_by_client: boolean; endpoint: boolean }
26
27const norm = (s: string): string => s.replace(/[‘’]/g, "'").split(/\s+/).filter(Boolean).join(' ').toLowerCase()
28const tid = (i: number): string => `t${i + 1}`
29
30let isCapturing = false
31let isTapMode = false
32let pid = 0
33let home = ''
34let dataHome = ''
35let projectData = ''
36let project = ''
37let work = ''
38let startedSecs = 0
39let thread: ThreadLine[] = []
40
41async function log($: EngineInterface, event: ThreadEvent): Promise<void> {
42  const line: ThreadLine = { n: thread.length + 1, at: await $.clock.now(), ...event }
43  thread = [...thread, line]
44  await $.fs.write(`${work}/thread.jsonl`, thread.map(e => JSON.stringify(e)).join('\n') + '\n')
45}
46
47const live = (list: Note[]): [string, Note][] =>
48  list.map((n, i): [string, Note] => [tid(i), n]).filter(([, n]) => !n.deleted)
49
50async function poll($: EngineInterface): Promise<void> {
51  const index = `${projectData}/index.jsonl`
52  if (!(await $.fs.exists(index))) return
53  const rows: IndexRow[] = (await $.fs.read(index))
54    .split('\n')
55    .filter((l: string) => l.trim())
56    .map((l: string) => JSON.parse(l))
57    .filter((r: IndexRow) => r.pid === pid && r.start >= startedSecs && r.final.trim())
58  const known = new Set((await read($, notes)).map(n => n.name))
59  const fresh = rows.filter(r => !known.has(r.name))
60  if (fresh.length === 0) return
61  const added: Note[] = fresh.map(r => ({
62    name: r.name, startMs: Math.round(r.start * 1000), text: r.final, wav: r.wav,
63    ok: r.closed_by_client && r.endpoint, sent: false, deleted: false,
64  }))
65  await update($, notes, list => [...list, ...added])
66  const count = (await read($, notes)).length
67  for (const n of added) await log($, { type: 'TakeRecorded', take: tid(count - added.length + added.indexOf(n)), stream: n.name, text: n.text, wav: n.wav, ok: n.ok })
68  const newest: Note | undefined = added[added.length - 1]
69  if (newest) await update($, latest, () => `${tid(count - 1)}: ${newest.text}`)
70  $.ui.status(`voice notes: ${count} captured`)
71  $.ui.toast(`Voice note ${tid(count - 1)} captured`)
72}
73
74async function place($: EngineInterface): Promise<void> {
75  const takes = live(await read($, notes))
76  if (takes.length === 0) {
77    await update($, message, () => 'No notes yet: dictate with /voice first.')
78    return
79  }
80  const dir = `${work}/place-${thread.length + 1}`
81  const transcript = takes.map(([t, note]) => `[${t}] ${note.text}`).join('\n\n')
82  await update($, busy, () => `Placing ${takes.map(([t]) => t).join(', ')}…`)
83  await update($, message, () => '')
84  const base = await read($, rev)
85  await log($, { type: 'JobSubmitted', base, ctx: takes.map(([t]) => t), dir })
86  const t0 = await $.clock.now()
87  try {
88    const system = await $.fs.read(`${$.plugin.root}/prompt/system.md`)
89    await $.fs.write(`${dir}/system.md`, system)
90    await $.fs.write(`${dir}/transcript.txt`, transcript)
91    const r = await $.model.complete({ model: MODEL, system, prompt: transcript, effort: EFFORT, timeoutMs: 300000 })
92    const secs = Math.round(((await $.clock.now()) - t0) / 100) / 10
93    await $.fs.write(`${dir}/call.json`, JSON.stringify({ model: MODEL, effort: EFFORT, secs, isAnswered: r.isAnswered, usage: r.usage }, null, 1))
94    if (!r.isAnswered) {
95      await log($, { type: 'JobFailed', reason: r.reason })
96      await update($, message, () => `The model gave no reply (${r.reason}).`)
97      return
98    }
99    await $.fs.write(`${dir}/reply.txt`, r.text)
100    const v = validate(transcript, r.text)
101    if (!v.ok) {
102      await log($, { type: 'JobFailed', violations: v.violations })
103      await update($, message, () => `The reply broke the contract: ${v.violations.map(x => x.violation).join(', ')}`)
104      return
105    }
106    const claims: Proposal['claims'] = {}
107    const asked: Proposal['asked'] = {}
108    for (const key of KEYS) {
109      const placement = v.placed[key]
110      if (placement.kind === 'claims') claims[key] = placement.claims
111      else asked[key] = placement.question
112    }
113    const p: Proposal = { base, ctx: takes.map(([t]) => t), claims, asked, secs }
114    await update($, proposal, () => p)
115    await log($, { type: 'ProposalMade', kind: 'place', secs, claims, asked })
116    await update($, message, () => `Proposal ready in ${secs}s. Accept it, or dictate an answer and place again.`)
117  } catch (err) {
118    await log($, { type: 'JobFailed', error: String(err) })
119    await update($, message, () => String(err))
120  } finally {
121    await update($, busy, () => null)
122  }
123}
124
125async function voiceMode($: EngineInterface): Promise<string | undefined> {
126  for (const path of [`${project}/.claude/settings.local.json`, `${project}/.claude/settings.json`, `${home}/.claude/settings.json`]) {
127    if (!(await $.fs.exists(path))) continue
128    try {
129      const mode: unknown = JSON.parse(await $.fs.read(path))?.voice?.mode
130      if (typeof mode === 'string') return mode
131    } catch {
132      continue
133    }
134  }
135  return undefined
136}
137
138async function dictated($: EngineInterface, text: string): Promise<string | null> {
139  for (let i = 0; i < DICTATION_WAIT_STEPS; i++) {
140    await poll($)
141    const hit = live(await read($, notes)).find(([, n]) => !n.sent && norm(n.text) === text)
142    if (hit) return hit[0]
143    await $.clock.sleep(250)
144  }
145  return null
146}
147
148async function clearDropped($: EngineInterface, text: string): Promise<void> {
149  await $.clock.sleep(150)
150  const box = await $.prompt.read()
151  if (norm(box.text) === text) await $.prompt.fill({ text: '' })
152}
153
154async function gitRoot($: EngineInterface, cwd: string): Promise<string> {
155  let dir = cwd
156  while (dir !== '' && dir !== '/') {
157    if (await $.fs.exists(`${dir}/.git`)) return dir
158    dir = dir.slice(0, dir.lastIndexOf('/'))
159  }
160  return cwd
161}
162
163async function setup($: EngineInterface): Promise<string> {
164  const mitmdump = `${home}/.local/bin/mitmdump`
165  if (!(await $.fs.exists(mitmdump))) return 'mitmdump is not at ~/.local/bin/mitmdump. Run `uv tool install mitmproxy`, then /vb-setup again.'
166  const addon = `${dataHome}/voice-brief/vb_mitm.py`
167  await $.fs.write(addon, await $.fs.read(`${$.plugin.root}/setup/vb_mitm.py`))
168  const config = (await $.env.get('XDG_CONFIG_HOME')) ?? `${home}/.config`
169  const unit = `${config}/systemd/user/vb-voice-proxy.service`
170  await $.fs.write(unit, (await $.fs.read(`${$.plugin.root}/setup/vb-voice-proxy.service.in`)).replaceAll('@ADDON@', addon))
171  for (const argv of [['systemctl', '--user', 'daemon-reload'], ['systemctl', '--user', 'enable', 'vb-voice-proxy.service'], ['systemctl', '--user', 'restart', 'vb-voice-proxy.service']]) {
172    const r = await $.process.run(argv)
173    if (r.exitCode !== 0) return `\`${argv.join(' ')}\` failed: ${r.stderr.trim()}`
174  }
175  const ca = `${home}/.mitmproxy/mitmproxy-ca-cert.pem`
176  for (let i = 0; i < 50 && !(await $.fs.exists(ca)); i++) await $.clock.sleep(100)
177  if (!(await $.fs.exists(ca))) return `The proxy started but ${ca} did not appear. See \`journalctl --user -u vb-voice-proxy\`.`
178  const path = `${project}/.claude/settings.local.json`
179  const current: { env?: Record<string, string> } = (await $.fs.exists(path)) ? JSON.parse(await $.fs.read(path)) : {}
180  current.env = { ...(current.env ?? {}), HTTPS_PROXY: PROXY_URL, NODE_EXTRA_CA_CERTS: ca }
181  await $.fs.write(path, JSON.stringify(current, null, 2) + '\n')
182  const ignore = (await $.fs.exists(`${project}/.gitignore`)) ? await $.fs.read(`${project}/.gitignore`) : ''
183  const warn = ignore.includes('settings.local.json') ? '' : ' Add `.claude/settings.local.json` to .gitignore: it holds a path under your home.'
184  await log($, { type: 'SetUp', unit, addon, settings: path })
185  return `Installed ${unit} (proxy on ${PROXY_URL}) and wrote ${path}. Restart Claude Code in ${project} to start capturing.${warn}`
186}
187
188async function resize($: EngineInterface, to: PaneSize): Promise<void> {
189  await update($, size, () => to)
190  await $.ui.close({ id: PANE })
191  if (to !== 'min') await $.ui.open({ id: PANE, title: 'Voice notes', columns: COLUMNS[to], rows: to === 'max' ? 40 : 16 })
192}
193
194async function isFresh($: EngineInterface, p: Proposal): Promise<boolean> {
195  const ids = live(await read($, notes)).map(([t]) => t)
196  return p.base === (await read($, rev)) && p.ctx.every(t => ids.includes(t))
197}
198
199async function accept($: EngineInterface): Promise<void> {
200  const p = await read($, proposal)
201  if (!p) return
202  if (!(await isFresh($, p))) {
203    await update($, message, () => 'Stale: a note it used was deleted. Place again.')
204    return
205  }
206  await update($, rev, r => r + 1)
207  await update($, accepted, () => p)
208  await update($, proposal, () => null)
209  const now = await read($, rev)
210  await log($, { type: 'Accepted', revision: now })
211  await update($, message, () => `Accepted as revision ${now}. Hand off when it says what you mean.`)
212}
213
214async function reject($: EngineInterface): Promise<void> {
215  if (!(await read($, proposal))) return
216  await update($, proposal, () => null)
217  await log($, { type: 'Rejected' })
218  await update($, message, () => 'Rejected.')
219}
220
221const promptMd = (p: Proposal): string =>
222  FIELDS.map(([key, title]) => {
223    const claims: Claim[] = p.claims[key] ?? []
224    const body = claims.length ? claims.map(c => `- ${c.text}`).join('\n') : `(open: ${p.asked[key] ?? ''})`
225    return `## ${title}\n\n${body}`
226  }).join('\n\n') + '\n'
227
228async function handOff($: EngineInterface, surface?: RenderSurface): Promise<void> {
229  const p = await read($, accepted)
230  if (!p) {
231    await update($, message, () => 'Accept a proposal first.')
232    return
233  }
234  const text = promptMd(p)
235  await $.fs.write(`${work}/prompt.md`, text)
236  $.ui.copy({ text, surface })
237  const now = await read($, rev)
238  await log($, { type: 'HandedOff', target: 'clipboard', revision: now, path: `${work}/prompt.md` })
239  await update($, message, () => `Revision ${now} copied to the clipboard and written to ${work}/prompt.md`)
240}
241
242async function remove($: EngineInterface, take: string): Promise<void> {
243  const i = Number(take.replace(/^t/, '')) - 1
244  await update($, notes, list => list.map((n, j) => (j === i ? { ...n, deleted: true } : n)))
245  await log($, { type: 'TakeDeleted', take })
246  await update($, message, () => `${take} deleted. Place again.`)
247}
248
249async function fix($: EngineInterface, take: string, text: string): Promise<void> {
250  const i = Number(take.replace(/^t/, '')) - 1
251  const before = (await read($, notes))[i]?.text
252  if (before === undefined || !text.trim()) return
253  await update($, notes, list => list.map((n, j) => (j === i ? { ...n, text } : n)))
254  await log($, { type: 'TranscriptCorrected', take, before, after: text })
255  await update($, message, () => `${take} corrected. Place again.`)
256}
257
258export const register: Register = on => {
259  on('session.start', async ($, e, next) => {
260    home = (await $.env.get('HOME')) ?? ''
261    dataHome = (await $.env.get('XDG_DATA_HOME')) ?? `${home}/.local/share`
262    isCapturing = (await $.env.get('HTTPS_PROXY')) === PROXY_URL
263    project = await gitRoot($, e.cwd)
264    isTapMode = (await voiceMode($)) === 'tap'
265    projectData = `${dataHome}/voice-brief/projects/${project.replaceAll('/', '-')}`
266    pid = Number((await $.fs.read('/proc/self/stat')).split(' ')[0])
267    startedSecs = (await $.clock.now()) / 1000 - 5
268    work = `${projectData}/notes/${Math.round(startedSecs)}`
269    thread = []
270    await log($, { type: 'SessionStarted', pid, project, isCapturing })
271    for (const name of ['vb', 'vb-min', 'vb-max', 'vb-place', 'vb-accept', 'vb-reject', 'vb-handoff', 'vb-delete', 'vb-fix', 'vb-setup']) {
272      await $.command.register({ name, description: `voice-brief: ${name === 'vb' ? 'open the notes pane' : name.slice(3)}` })
273    }
274    $.clock.every(1000, () => void poll($))
275    $.ui.status(isCapturing ? 'voice notes: listening' : 'voice notes: not capturing (/vb-setup)')
276    void resize($, await read($, size))
277    return next(e)
278  })
279
280  on('command.run', async ($, e, next) => {
281    if (!e.command.startsWith('vb')) return next(e)
282    const [take = '', ...rest] = e.args.trim().split(/\s+/)
283    switch (e.command) {
284      case 'vb': await resize($, 'normal'); return { text: 'Voice notes pane opened.' }
285      case 'vb-min': await resize($, 'min'); return { text: 'Voice notes minimized to the line above the prompt.' }
286      case 'vb-max': await resize($, 'max'); return { text: 'Voice notes maximized.' }
287      case 'vb-place': await place($); return { text: (await read($, message)) || 'Placed.' }
288      case 'vb-accept': await accept($); return { text: await read($, message) }
289      case 'vb-reject': await reject($); return { text: await read($, message) }
290      case 'vb-handoff': await handOff($); return { text: await read($, message) }
291      case 'vb-delete': await remove($, take); return { text: await read($, message) }
292      case 'vb-fix': await fix($, take, rest.join(' ')); return { text: await read($, message) }
293      case 'vb-setup': return { text: await setup($) }
294      default: return next(e)
295    }
296  })
297
298  on('prompt.submit', async ($, e, next) => {
299    const text = norm(e.text)
300    if (isTapMode && isCapturing && !e.command && !e.isMeta && text) {
301      const take = await dictated($, text)
302      if (take) {
303        await update($, latest, () => null)
304        void clearDropped($, text).catch(() => undefined)
305        return { drop: `Kept as voice note ${take}; not sent to the agent (tap mode).` }
306      }
307    }
308    const list = await read($, notes)
309    const sent = list.map((n, i) => (!n.sent && norm(n.text) && text.includes(norm(n.text)) ? i : -1)).filter(i => i >= 0)
310    if (sent.length) {
311      await update($, notes, l => l.map((n, i) => (sent.includes(i) ? { ...n, sent: true } : n)))
312      for (const i of sent) await log($, { type: 'TakeSent', take: tid(i) })
313    }
314    await update($, latest, () => null)
315    return next(e)
316  })
317
318  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
319    const last = await read($, latest)
320    const isMin = (await read($, size)) === 'min'
321    if (!last && !isMin) return next(e)
322    const { Box, Text, Button } = $.ui.resolve(e)
323    const list = await read($, notes)
324    const p = await read($, proposal)
325    const acc = await read($, accepted)
326    const working = await read($, busy)
327    const state = working ?? (p ? 'proposal ready' : acc ? `revision ${await read($, rev)} accepted` : 'no proposal')
328    return (
329      <Box flexDirection="column">
330        {last && (
331          <Box>
332            <Text color="success">● captured </Text>
333            <Text wrap="truncate-end">{last}</Text>
334            <Text dimColor>   saved to notes; Enter also sends it to Claude</Text>
335          </Box>
336        )}
337        {isMin && (
338          <Box gap={1}>
339            <Text color="claude">Voice notes</Text>
340            <Text dimColor>{live(list).length} notes · {state}</Text>
341            <Button key="restore" label="Open" onPress={() => void resize($, 'normal')} />
342            <Button key="max" label="Maximize" onPress={() => void resize($, 'max')} />
343          </Box>
344        )}
345      </Box>
346    )
347  })
348
349  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
350    const { Box, Text, Button } = $.ui.resolve(e)
351    const list = await read($, notes)
352    const p = await read($, proposal)
353    const acc = await read($, accepted)
354    const r = await read($, rev)
355    const working = await read($, busy)
356    const msg = await read($, message)
357    const view = p ?? acc
358    const stale = p ? !(await isFresh($, p)) : false
359    const takeOf = (span: string): string => {
360      const s = norm(span)
361      const i = list.findIndex(n => !n.deleted && norm(n.text).includes(s))
362      return i >= 0 ? tid(i) : '?'
363    }
364    return (
365      <Box flexDirection="column" gap={1}>
366        <Box gap={1}>
367          <Button key="min" label="Minimize" onPress={() => void resize($, 'min')} />
368          {(await read($, size)) === 'max'
369            ? <Button key="restore" label="Restore" onPress={() => void resize($, 'normal')} />
370            : <Button key="max" label="Maximize" onPress={() => void resize($, 'max')} />}
371        </Box>
372        <Box flexDirection="column">
373          <Text bold>Notes ({live(list).length}) <Text dimColor>{project}</Text></Text>
374          {!isCapturing && <Text color="warning">Not capturing: this session does not go through the voice proxy. Run /vb-setup, then restart Claude Code.</Text>}
375          {isCapturing && list.length === 0 && <Text dimColor>Dictate with /voice. Each dictation lands here; Enter is optional.</Text>}
376          {list.map((n, i) => (
377            <Box key={n.name}>
378              <Text dimColor={n.deleted} strikethrough={n.deleted}>
379                <Text bold>{tid(i)}</Text> {n.sent ? '↗ ' : ''}{n.text}
380              </Text>
381              {!n.deleted && <Button key={`del-${n.name}`} label="×" onPress={() => void remove($, tid(i))} />}
382            </Box>
383          ))}
384        </Box>
385        <Box gap={1}>
386          <Button key="place" variant="primary" label="Place" onPress={() => void place($)} />
387          {p && <Button key="accept" label="Accept" onPress={() => void accept($)} />}
388          {p && <Button key="reject" label="Reject" onPress={() => void reject($)} />}
389          {acc && <Button key="handoff" label="Hand off" onPress={press => void handOff($, press.surface)} />}
390        </Box>
391        {working && <Text color="warning">{working}</Text>}
392        {msg !== '' && <Text dimColor>{msg}</Text>}
393        {view && (
394          <Box flexDirection="column">
395            <Text bold>{p ? `Proposal from ${p.ctx.join(', ')}${stale ? '  (stale)' : ''}` : `Accepted, revision ${r}`}</Text>
396            {FIELDS.map(([key, title]) => (
397              <Box key={key} flexDirection="column" marginTop={1}>
398                <Text color="claude">{title}</Text>
399                {(view.claims[key] ?? []).map(c => (
400                  <Box key={c.id} flexDirection="column" paddingLeft={2}>
401                    <Text>{c.text}</Text>
402                    <Text dimColor>{c.said ? `said ${[...new Set(c.said.map(takeOf))].join(', ')}` : `added: ${c.added}`}</Text>
403                  </Box>
404                ))}
405                {view.asked[key] && <Text color="warning">  ? {view.asked[key]}</Text>}
406              </Box>
407            ))}
408          </Box>
409        )}
410      </Box>
411    )
412  })
413}
414
hooks/contract.ts 126 lines
1import type { Claim, Field } from '../types'
2
3export type { Claim, Field }
4
5export const TITLES: Record<Field, string> = {
6  weakest_part: 'Weakest part',
7  force: 'Force',
8  smallest_move: 'Smallest move',
9  preserves: 'Preserves',
10  would_reverse_it: 'Would reverse it',
11}
12
13export const FIELDS: Field[] = Object.keys(TITLES) as Field[]
14
15export type Placement = { kind: 'claims'; claims: Claim[] } | { kind: 'asked'; question: string }
16
17export type Violation =
18  | { violation: 'malformed'; detail: string }
19  | { violation: 'unknown_field'; detail: string }
20  | { violation: 'missing_field'; detail: Field }
21  | { violation: 'empty_without_question'; detail: Field }
22  | { violation: 'claims_and_question'; detail: Field }
23  | { violation: 'claim_without_source'; detail: { field: Field; claim: string } }
24  | { violation: 'claim_with_both_sources'; detail: { field: Field; claim: string } }
25  | { violation: 'duplicate_claim_id'; detail: string }
26  | { violation: 'empty_span'; detail: { field: Field; claim: string } }
27  | { violation: 'span_not_in_transcript'; detail: { field: Field; claim: string; span: string } }
28
29export type Placed = Record<Field, Placement>
30
31export type Validated = { ok: true; placed: Placed } | { ok: false; violations: Violation[] }
32
33type WireClaim = { id?: unknown; text?: unknown; said?: unknown; added?: unknown }
34type WireField = { claims?: unknown; question?: unknown }
35
36const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
37const isField = (k: string): k is Field => (FIELDS as string[]).includes(k)
38
39export function normalize(text: string): string {
40  return text.replace(/[‘’]/g, "'").replace(/[“”]/g, '"').split(/\s+/).filter(Boolean).join(' ')
41}
42
43function shapeError(where: string): Validated {
44  return { ok: false, violations: [{ violation: 'malformed', detail: where }] }
45}
46
47function wireClaims(field: WireField): WireClaim[] | null {
48  const claims: unknown = field.claims ?? []
49  if (!Array.isArray(claims) || !claims.every(isRecord)) return null
50  return claims as WireClaim[]
51}
52
53export function validate(transcript: string, reply: string): Validated {
54  let wire: unknown
55  try {
56    wire = JSON.parse(reply.trim())
57  } catch (e) {
58    return shapeError(String(e))
59  }
60  if (!isRecord(wire) || Object.keys(wire).some(k => k !== 'fields') || !isRecord(wire.fields)) {
61    return shapeError('expected {"fields": {...}}')
62  }
63  const fields: Record<string, unknown> = wire.fields
64  const haystack: string = normalize(transcript)
65  const violations: Violation[] = []
66  const ids = new Set<string>()
67  const placed: Partial<Placed> = {}
68
69  for (const key of Object.keys(fields)) {
70    if (!isField(key)) violations.push({ violation: 'unknown_field', detail: key })
71  }
72  for (const field of FIELDS) {
73    const raw: unknown = fields[field]
74    if (raw === undefined) {
75      violations.push({ violation: 'missing_field', detail: field })
76      continue
77    }
78    if (!isRecord(raw) || Object.keys(raw).some(k => k !== 'claims' && k !== 'question')) {
79      return shapeError(`field ${field}`)
80    }
81    const claims = wireClaims(raw)
82    if (claims === null || (raw.question !== undefined && raw.question !== null && typeof raw.question !== 'string')) {
83      return shapeError(`field ${field}`)
84    }
85    const question: string = typeof raw.question === 'string' ? raw.question.trim() : ''
86    if (claims.length === 0 && question === '') {
87      violations.push({ violation: 'empty_without_question', detail: field })
88    } else if (claims.length === 0) {
89      placed[field] = { kind: 'asked', question }
90    } else if (question !== '') {
91      violations.push({ violation: 'claims_and_question', detail: field })
92    } else {
93      const out: Claim[] = []
94      for (const c of claims) {
95        const keys = Object.keys(c)
96        if (typeof c.id !== 'string' || typeof c.text !== 'string' || keys.some(k => !['id', 'text', 'said', 'added'].includes(k))) {
97          return shapeError(`claim in ${field}`)
98        }
99        const said: unknown = c.said ?? []
100        if (!Array.isArray(said) || !said.every(s => typeof s === 'string') || (c.added !== undefined && c.added !== null && typeof c.added !== 'string')) {
101          return shapeError(`claim ${c.id} in ${field}`)
102        }
103        if (ids.has(c.id)) violations.push({ violation: 'duplicate_claim_id', detail: c.id })
104        ids.add(c.id)
105        const added: string | undefined = typeof c.added === 'string' ? c.added : undefined
106        if (said.length === 0 && added === undefined) {
107          violations.push({ violation: 'claim_without_source', detail: { field, claim: c.id } })
108          continue
109        }
110        if (said.length > 0 && added !== undefined) {
111          violations.push({ violation: 'claim_with_both_sources', detail: { field, claim: c.id } })
112          continue
113        }
114        for (const span of said as string[]) {
115          const needle: string = normalize(span)
116          if (needle === '') violations.push({ violation: 'empty_span', detail: { field, claim: c.id } })
117          else if (!haystack.includes(needle)) violations.push({ violation: 'span_not_in_transcript', detail: { field, claim: c.id, span } })
118        }
119        out.push(added !== undefined ? { id: c.id, text: c.text, added } : { id: c.id, text: c.text, said: said as string[] })
120      }
121      placed[field] = { kind: 'claims', claims: out }
122    }
123  }
124  return violations.length === 0 ? { ok: true, placed: placed as Placed } : { ok: false, violations }
125}
126
types/index.d.ts 40 lines
1// @generated by `cargo run -q -p vb-thread --bin thread-ts -- <file>` from desktop/thread/src/wire.rs
2
3export type Field = "weakest_part" | "force" | "smallest_move" | "preserves" | "would_reverse_it";
4
5export type Claim = { id: string, text: string, said?: Array<string>, added?: string, };
6
7export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]: JsonValue } | null;
8
9export type Violation = { violation: string, detail: JsonValue, };
10
11export type Failure = { violations: Array<Violation>, } | { reason: string, } | { error: string, };
12
13export type ProposalKind = "place";
14
15export type HandoffTarget = "clipboard";
16
17export type ThreadEvent = { "type": "SessionStarted", pid: number, project: string, isCapturing?: boolean, } | { "type": "SetUp", unit: string, addon: string, settings: string, } | { "type": "TakeRecorded", take: string, stream: string, text: string, wav: string, ok: boolean, } | { "type": "TakeSent", take: string, } | { "type": "TakeDeleted", take: string, } | { "type": "TranscriptCorrected", take: string, before: string, after: string, } | { "type": "JobSubmitted", base: number, ctx: Array<string>, dir: string, } | { "type": "JobFailed", } & ({ violations: Array<Violation>, } | { reason: string, } | { error: string, }) | { "type": "ProposalMade", kind: ProposalKind, secs: number, claims: { [key in Field]?: Array<Claim> }, asked: { [key in Field]?: string }, } | { "type": "Accepted", revision: number, } | { "type": "Rejected", } | { "type": "HandedOff", target: HandoffTarget, revision: number, path: string, };
18
19export type ThreadLine = { n: number, at: number, } & ({ "type": "SessionStarted", pid: number, project: string, isCapturing?: boolean, } | { "type": "SetUp", unit: string, addon: string, settings: string, } | { "type": "TakeRecorded", take: string, stream: string, text: string, wav: string, ok: boolean, } | { "type": "TakeSent", take: string, } | { "type": "TakeDeleted", take: string, } | { "type": "TranscriptCorrected", take: string, before: string, after: string, } | { "type": "JobSubmitted", base: number, ctx: Array<string>, dir: string, } | { "type": "JobFailed", } & ({ violations: Array<Violation>, } | { reason: string, } | { error: string, }) | { "type": "ProposalMade", kind: ProposalKind, secs: number, claims: { [key in Field]?: Array<Claim> }, asked: { [key in Field]?: string }, } | { "type": "Accepted", revision: number, } | { "type": "Rejected", } | { "type": "HandedOff", target: HandoffTarget, revision: number, path: string, });
20
21// @end-generated
22export type Note = { name: string; startMs: number; text: string; wav: string; ok: boolean; sent: boolean; deleted: boolean }
23export type PaneSize = 'min' | 'normal' | 'max'
24export type Proposal = { base: number; ctx: string[]; claims: { [key in Field]?: Claim[] }; asked: { [key in Field]?: string }; secs: number }
25
26declare module 'claude-code' {
27  interface PluginState {
28    'voice-notes': {
29      notes: Shaped<Note[]>
30      proposal: Shaped<Proposal | null>
31      accepted: Shaped<Proposal | null>
32      rev: number
33      busy: string | null
34      message: string
35      latest: string | null
36      size: PaneSize
37    }
38  }
39}
40