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

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.
/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:
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;~/.local/share/voice-brief/;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.
/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.
| Command | Does |
|---|---|
/vb, /vb-min, /vb-max | open, minimize to one line above the prompt, maximize |
/vb-place | place every live note into the five fields |
/vb-accept, /vb-reject | accept or reject the proposal |
/vb-handoff | copy 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-setup | install 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 ->/.
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.
hooks/register.tsx 414 lines1import { 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}
414hooks/contract.ts 126 lines1import 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}
126types/index.d.ts 40 lines1// @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