Side panel with the questions most likely to come up before a pitch, meeting or daily stand-up, with answers ranked by risk; listens along via Wispr Flow

Know the question before it is asked, and what you can safely say.
A side panel for Claude Code. Before a pitch, a customer meeting or the daily stand-up, it reads your repository, your tickets and, with Wispr Flow, earlier meetings, and shows the questions most likely to come up, each with answers ranked by risk and the source they rest on. During the meeting it listens along and predicts live which questions come next, with their answers ready before anyone asks.
/plugin install great-question --marketplace jsc2304/great-question
Then, in the repository you work in:
/great-question-prep today's daily
The panel opens and the questions appear when the background agent is done. For a meeting, name it instead: /great-question-prep customer pitch Thursday. More under Installation and Usage.
Every answer carries its risk as a mark, a word and a colour, safest first:
| Mark | Meaning |
|---|---|
| ● SAY IT · sourced | well founded, with a source |
| ○ WEAK · vague | true and binds us to nothing, but shallow |
| ▲ ONLY IF COVERED · commitment | binds us (date, price, scope); say it only when the documents back it |
| ✖ DON'T SAY · risky | unsupported, attackable or contradicting the documents |
A pitch prepared: what you worked on last as safe to say, then questions grouped by how surely they can be answered, each with its risk marks. An opened question says who is likely to ask, why, who answers on our side, and offers answers by risk, with their sources:

Where the documents give no firm answer, the panel offers, safest first, what is harmless but shallow, what you may promise only if it is covered, and what to leave out:

Listening along: the question just asked moves to ASKED NOW with answers by risk, your recent work stays on show as safe to say, and the next likely questions appear below, each with its safest answer in one line:

While a question is open, new ones wait behind a button instead of moving the list:

Screenshots show a fictional pitch for a fictional company; no real meeting was recorded.
/great-question-prep finds the upcoming meeting with its attendees and earlier conversations with the other side.In a Claude Code session in the terminal, enter:
/plugin install great-question --marketplace jsc2304/great-question
Confirm the marketplace question with y and choose the scope User (Enter). The mod then applies to all projects, including sessions in the desktop app.
Without a running session, use the shell:
claude plugin marketplace add jsc2304/great-question
claude plugin install great-question@great-question --scope user
Sessions that were running before the installation only see the mod after a restart.
claude plugin update great-question@great-question
Then restart the session. The loaded version is shown top right in the panel (for example v0.9.0).
| Command | Effect |
|---|---|
/great-question | opens the panel without generating anything |
/great-question-prep [occasion] | finds likely questions with answers in the background, for example /great-question-prep steering committee Thursday |
The desktop app does not list these commands in its suggestions. Type them in full and press Enter.
With nothing prepared yet, the panel asks where to start: Likely questions about <folder>, Prepare my next meeting (the background agent finds it in your calendar) or Prepare today's daily. Or just Listen live: questions then come from the live transcript only, answers from the repository.
Sources and the sources of answers are links where they point somewhere: a URL, a GitHub issue or pull request (owner/repo#12, #12), or a repository file, which opens on GitHub at its line (docs/ops.md:12). A file path counts from the session's folder; it becomes a link only when the file exists there and its repository's default branch on GitHub has it, otherwise it stays plain text.
Buttons in the panel:
/great-question-prep without an occasion. A background agent reads the repository and connected sources while your session stays free; the panel shows a turning ◐ with the time taken, and the questions appear when it is done.question-sets/YYYY-MM-DD-HHMM-<title>.md in the project folder.While listening, the panel follows the questions you have to answer:
While listening, answers come from the repository too:
git grep, exports left out) and lets the model answer from the lines found. A SAY IT answer then names its file and line, for example docs/offer.md:42.While listening, the list only grows, it is never rebuilt:
What gets prepared depends on the project: with slides, a pitch preparation or a meeting at hand it prepares the meeting; otherwise the daily stand-up from commits, pull requests and open tickets.
question-sets/ to .gitignore or review it before committing.Check:
claude plugin validate .
claude plugin test .
A change reaches running sessions only with a release, since the mod installs from this repository on GitHub: raise version in .claude-plugin/plugin.json and add the CHANGELOG.md section in the same pull request, merge, tag vX.Y.Z, then run claude plugin marketplace update great-question and claude plugin update great-question@great-question and restart the session. The desktop app loads the mod from the plugin cache, not from the working folder, so unmerged work never reaches it; test it with claude plugin test ..
| File | Contents |
|---|---|
hooks/register.tsx | commands, the question-set tool, panel, export |
hooks/live.ts | listening along: read the transcript, fold in the model's reply |
types/index.d.ts | data types and the panel's state |
tests/ | tests for panel, export and listening along |
Questions, ideas and meeting stories go to Discussions; bugs to Issues. If Great Question saved you in a meeting, a star helps others find it.
MIT, see LICENSE.
hooks/register.tsx 1029 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Answer, Fact, Lang, Quality, Live, Question, QuestionSet, Risk, Source, SourceKind } from '../types'
5import {
6 GROUND_SYSTEM,
7 IDLE,
8 SYSTEM,
9 applyGrounding,
10 applyReply,
11 asText,
12 githubBase,
13 grepArgs,
14 ground,
15 knowledge,
16 languageRule,
17 logArgs,
18 lookupPrompt,
19 parseIdent,
20 parseLog,
21 parseTranscript,
22 recentOf,
23 rowKey,
24 parseSource,
25 settleLookup,
26 task,
27 toAnswers,
28 topHits,
29 withRisks,
30} from './live'
31
32const PANE = 'great-question'
33const TOOL = 'mcp__great-question__show_questions'
34const current = atom({ plugin: 'great-question', key: 'set' } as const, null)
35// Keyed by question text, so a row keeps its state while predictions and groups reshuffle around it.
36const opened = atom({ plugin: 'great-question', key: 'open' } as const, [])
37// While a row is open the live sections stand still; new rows wait behind the "take new" button.
38const held = atom({ plugin: 'great-question', key: 'held' } as const, null)
39const more = atom({ plugin: 'great-question', key: 'more' } as const, false)
40// A background agent is finding likely questions: since when, for which occasion. `tick` drives its spinner.
41const preparing = atom({ plugin: 'great-question', key: 'preparing' } as const, null)
42const tick = atom({ plugin: 'great-question', key: 'tick' } as const, 0)
43// Where sources point, by label, worked out with git when questions arrive; '' for nowhere. Read while drawing.
44const links = atom({ plugin: 'great-question', key: 'links' } as const, {})
45// meeting: surest answers first, to answer from; practice: weakest first, to practise.
46const view = atom({ plugin: 'great-question', key: 'view' } as const, 'meeting')
47const live = atom({ plugin: 'great-question', key: 'live' } as const, IDLE)
48// confidence: grouped by answer confidence; likelihood: grouped by how likely the question comes. Relevance leads inside a group.
49const sortBy = atom({ plugin: 'great-question', key: 'sortBy' } as const, 'confidence')
50// Language of new questions and answers; nothing already shown is translated.
51const lang = atom({ plugin: 'great-question', key: 'lang' } as const, 'auto')
52const LANGS: Lang[] = ['auto', 'de', 'en']
53const version = atom({ plugin: 'great-question', key: 'version' } as const, '')
54
55const LISTEN_MS = 20_000
56// A recording counts as running while Wispr wrote its live transcript this recently.
57const FRESH_MS = 3 * 60_000
58const quality = atom({ plugin: 'great-question', key: 'quality' } as const, 'fast')
59// Who answers live and how hard it thinks. A slower level answers later: a step still running when the next is due skips it.
60// A completion has no time limit of its own: one that never answered held the listening step, and every later one, for good.
61const QUALITY: Record<Quality, { label: string; model: string; effort?: 'low' | 'high'; timeoutMs: number }> = {
62 fast: { label: 'Fast (Haiku)', model: 'haiku', timeoutMs: 30_000 },
63 balanced: { label: 'Balanced (Sonnet, low)', model: 'sonnet', effort: 'low', timeoutMs: 45_000 },
64 thorough: { label: 'Thorough (Sonnet, high)', model: 'sonnet', effort: 'high', timeoutMs: 90_000 },
65}
66const QUALITIES = Object.keys(QUALITY) as Quality[]
67// Reads that come from an older version or a hand edit fall back to the fast level.
68const levelOf = async ($: EngineInterface) => QUALITY[await read($, quality)] ?? QUALITY.fast
69
70// Display order: safest first, so the answer to give leads and the warning closes. A thin answer that binds us to
71// nothing comes before a promise: a closed row's one-line answer is never a commitment while a safer one exists.
72// `action` says what to do with the answer in the room; `label` is the risk class.
73const RISKS: Record<Risk, { mark: string; label: string; action: string; color: string }> = {
74 sourced: { mark: '●', label: 'sourced', action: 'SAY IT', color: 'success' },
75 vague: { mark: '○', label: 'vague', action: 'WEAK', color: 'inactive' },
76 commitment: { mark: '▲', label: 'commitment', action: 'ONLY IF COVERED', color: 'warning' },
77 risky: { mark: '✖', label: 'risky', action: "DON'T SAY", color: 'error' },
78}
79const ORDER = Object.keys(RISKS) as Risk[]
80
81// Grouped by answer confidence, surest first; relevance breaks ties.
82const GROUPS = [
83 { label: 'Safe to answer', min: 5 },
84 { label: 'Well sourced', min: 4 },
85 { label: 'Partly sourced', min: 3 },
86 { label: 'Unsure', min: 1 },
87]
88
89// Sets stored before `confidence` existed: a sourced answer counts as well covered.
90const LIKELY_GROUPS = [
91 { label: 'Very likely', min: 5 },
92 { label: 'Likely', min: 4 },
93 { label: 'Possible', min: 3 },
94 { label: 'Rather rare', min: 1 },
95]
96
97const confidenceOf = (q: Question) => q.confidence ?? (q.answers.some(a => a.risk === 'sourced') ? 4 : 2)
98const bySurest = (a: Question, b: Question) => confidenceOf(b) - confidenceOf(a) || b.relevance - a.relevance
99
100const SCHEMA = {
101 type: 'object',
102 required: ['title', 'mode', 'questions'],
103 properties: {
104 title: { type: 'string', description: 'Occasion, e.g. "Customer pitch, 14.10." or "Daily 13.10."' },
105 language: { type: 'string', enum: ['de', 'en'], description: 'Language the questions and answers are written in' },
106 mode: { type: 'string', enum: ['pitch', 'meeting', 'standup'] },
107 sources: {
108 type: 'array',
109 description: 'Sources read, each with its origin',
110 items: {
111 type: 'object',
112 required: ['kind', 'label'],
113 properties: {
114 kind: {
115 type: 'string',
116 enum: ['meeting', 'repo', 'ticket', 'doc', 'calendar', 'other'],
117 description: 'meeting: meeting notes and transcripts; repo: files in the repository; ticket: issues, pull requests, Jira; doc: documents outside the repository (Drive, offer, mail); calendar: appointments; other: the rest',
118 },
119 label: { type: 'string', description: 'What exactly, e.g. file path, meeting with date, ticket number' },
120 },
121 },
122 },
123 recent: {
124 type: 'array',
125 description: 'What I (the person preparing) worked on most recently, newest first: each one sentence that is certainly true and can be said in any meeting',
126 items: {
127 type: 'object',
128 required: ['text', 'source'],
129 properties: {
130 text: { type: 'string' },
131 source: { type: 'string', description: 'The commit, pull request, ticket or meeting with date that shows it' },
132 },
133 },
134 },
135 questions: {
136 type: 'array',
137 items: {
138 type: 'object',
139 required: ['question', 'relevance', 'confidence', 'answers'],
140 properties: {
141 question: { type: 'string' },
142 relevance: { type: 'integer', minimum: 1, maximum: 5, description: 'Likelihood × weight, 5 = highest' },
143 confidence: {
144 type: 'integer',
145 minimum: 1,
146 maximum: 5,
147 description: 'Answer confidence: how well the documents support a sourced answer; 5 = firm, sourced answer, 1 = no reliable answer',
148 },
149 who: { type: 'string', description: 'Who on the other side is likely to ask, as a role without names' },
150 why: { type: 'string', description: 'Why we expect the question: trigger or evidence (slide, gap, objection, earlier meeting)' },
151 answerer: {
152 type: 'string',
153 description: 'Who on our side answers, per briefing, speaker plan or responsibility; leave out when the documents name nobody',
154 },
155 answers: {
156 type: 'array',
157 items: {
158 type: 'object',
159 required: ['text', 'risk'],
160 properties: {
161 text: { type: 'string' },
162 risk: { type: 'string', enum: ['sourced', 'vague', 'commitment', 'risky'] },
163 note: { type: 'string', description: 'What to watch out for' },
164 source: { type: 'string', description: 'Evidence (slide, document, figure)' },
165 },
166 },
167 },
168 },
169 },
170 },
171 },
172}
173
174// How the prepared questions come back: from a background agent as its reply, from the session through the tool.
175const REPLY_AS_JSON = `Reply with one JSON object only, no text before or after it, in this shape (JSON Schema): ${JSON.stringify(SCHEMA)}`
176const CALL_THE_TOOL = `Load the tool ${TOOL} with ToolSearch and call it with the result. Afterwards, only one line in the chat.`
177
178const prepPrompt = (occasion: string, language: Lang, finish: string) => `Great Question: find the likely questions${occasion ? ` for: ${occasion}` : ''}.
179
180Language: ${language === 'auto' ? 'Write "question" and the answer "text"s in the language the meeting is held in (invitation, slides, earlier transcripts with the other side); if unsure, German.' : languageRule(language)} who, why, answerer, note and title in English. Return the language used as "language".
181
1821. Choose the mode. Is a pitch, presentation or meeting coming up (slides, pitch preparation, agenda, calendar entry, occasion above)? Then "pitch" or "meeting". Otherwise "standup": daily stand-up preparation from the commits and pull requests of the last working days, open and own issues/tickets, blockers.
1832. Gather context, only from sources you read in this session: README, slides and speaker notes, Q&A/FAQ preparation, offer, decisions, open risks, issues; connected sources (Jira, Drive) where they fit the occasion. Project rules (AGENTS.md, CLAUDE.md) apply.
184 Meeting transcripts from Wispr Flow, if connected: find matching meetings with search_meetings (title, attendees, period; the search only matches title and summary, not the transcript), then read get_meeting with view_transcript, not just the notes. What matters: earlier meetings with the other side (which questions and objections came up, what was promised) and internal preparation rounds (open points, agreements). Find the upcoming meeting and its attendees with list_upcoming_meetings. For "standup", the meetings since the last working day. If the other side asked a question before, its relevance rises; name the meeting and date in why.
1853. 8 to 15 questions the other side is most likely to ask, with relevance 1 to 5 (likelihood × weight) and confidence 1 to 5 (how well our documents support a sourced answer; 5 = firm, sourced answer, 1 = no reliable answer, open points lower it). Per question: who (who on the other side asks, as a role, no names of customer people), why (why we expect the question), answerer (who on our side answers, only when a briefing, speaker plan or responsibility names them), 2 to 4 answer options, each with a risk:
186 - sourced: well founded, source in "source" (slide, document, figure); low risk
187 - vague: harmless and shallow; low risk, but weak
188 - commitment: binds us (date, price, scope, people); only when the documents cover it
189 - risky: unsupported, contradicts the documents or is attackable; as a warning of what not to say
190 Optional "note": what to watch out for.
1914. "recent": 3 to 6 things I (the person preparing: the git user, my own tickets) did most recently, newest first, in the language of the answers. Each one sentence I can say in any meeting whatever is asked, and certainly true: only what a commit, pull request, ticket or meeting you read shows, with it as "source".
1925. ${finish}`
193
194const storeKey = async ($: EngineInterface) => `set:${await $.session.cwd()}`
195
196/** One git command's output, trimmed; null when git says no. A command that cannot run at all throws. */
197const git = async ($: EngineInterface, args: readonly string[], cwd: string) => {
198 const ran = await $.process.run(['git', ...args], { cwd, timeoutMs: 5000 })
199 return ran.exitCode === 0 ? ran.stdout.trim() : null
200}
201
202/**
203 * A source's https address: a URL as is, an issue, or a file that exists here and in its repository's default branch
204 * on GitHub, at its line. A label's path is relative to the session's folder, which need not be a repository's root.
205 */
206const resolveLink = async ($: EngineInterface, label: string, cwd: string): Promise<string | null> => {
207 const ref = parseSource(label)
208 if (!ref) return null
209 if ('url' in ref) return ref.url
210 if ('repo' in ref) return `https://github.com/${ref.repo}/issues/${ref.issue}`
211 if ('issue' in ref) {
212 const base = githubBase(await git($, ['remote', 'get-url', 'origin'], cwd))
213 return base && `${base}/issues/${ref.issue}`
214 }
215 const abs = ref.path.startsWith('/') ? ref.path : `${cwd}/${ref.path}`
216 if (!(await $.fs.exists(abs))) return null
217 const root = await git($, ['rev-parse', '--show-toplevel'], abs.slice(0, abs.lastIndexOf('/')))
218 if (!root || !abs.startsWith(`${root}/`)) return null
219 const base = githubBase(await git($, ['remote', 'get-url', 'origin'], root))
220 if (!base) return null
221 const rel = abs.slice(root.length + 1)
222 // Only a file the remote has: one that exists just locally, or on a local branch, would 404 on GitHub.
223 for (const ref_ of ['origin/HEAD', 'origin/main', 'origin/master']) {
224 if ((await git($, ['cat-file', '-e', `${ref_}:${rel}`], root)) !== null) return `${base}/blob/HEAD/${rel}${ref.line ? `#L${ref.line}` : ''}`
225 }
226 return null
227}
228
229// Labels being resolved now, so a redraw does not start them twice; whether a failure was reported this session.
230const resolving = new Set<string>()
231let isFailureLogged = false
232
233/**
234 * Works out the links of labels not seen yet and keeps them, so drawing only looks them up. A label whose lookup
235 * failed (git could not run) is not kept, so the next drawing tries again.
236 */
237const linkAll = async ($: EngineInterface, labels: readonly (string | undefined)[], folder?: string) => {
238 // Paths count from the folder the questions were prepared in: the session's own moves with its shell.
239 const cwd = folder ?? (await $.session.cwd())
240 const known = await read($, links)
241 const fresh = [...new Set(labels.filter((l): l is string => !!l))].filter(l => !(l in known) && !resolving.has(l))
242 if (!fresh.length) return
243 fresh.forEach(l => resolving.add(l))
244 const found: [string, string][] = []
245 for (const l of fresh) {
246 try {
247 found.push([l, (await resolveLink($, l, cwd)) ?? ''])
248 } catch (err) {
249 if (!isFailureLogged) $.ui.log(`Great Question: could not resolve source links: ${String(err).slice(0, 200)}`)
250 isFailureLogged = true
251 } finally {
252 resolving.delete(l)
253 }
254 }
255 if (found.length) await update($, links, k => ({ ...k, ...Object.fromEntries(found) }))
256}
257
258const answerSources = (questions: readonly Question[]) => questions.flatMap(q => q.answers.map(a => a.source))
259const setLabels = (set: QuestionSet) => [...set.sources.map(src => asSource(src).label), ...answerSources(set.questions), ...(set.recent ?? []).map(f => f.source)]
260
261/** Links for every source the pane can show that has none yet. Runs from a timer: drawing may not write state. */
262const linkPending = async ($: EngineInterface) => {
263 const stored = await read($, current)
264 const ear = { ...IDLE, ...(await read($, live)) }
265 if (stored) await linkAll($, setLabels(stored), stored.folder)
266 await linkAll($, [...answerSources([...ear.added, ...ear.next]), ...Object.values(ear.deep ?? {}).flatMap(list => list.map(a => a.source))])
267}
268
269// The background agent finding likely questions, and the spinner's clock while it runs.
270let prepAgent: string | null = null
271let stopTick: { cancel: () => void } | null = null
272
273/**
274 * Finds likely questions in a background agent, so the session stays free; without one, the session does it as before.
275 * `label` names the occasion in the spinner when `occasion` is a whole brief.
276 */
277const startPrep = async ($: EngineInterface, occasion: string, label = occasion) => {
278 if (await read($, preparing)) {
279 $.ui.toast('Great Question: already finding likely questions …')
280 return
281 }
282 const language = await read($, lang)
283 const cwd = await $.session.cwd()
284 const spawned = await $.agent
285 .spawn({ prompt: prepPrompt(occasion.trim(), language, REPLY_AS_JSON), description: 'Great Question: finding likely questions', subagentType: 'general-purpose' })
286 .catch(() => null)
287 if (!spawned || !('agentId' in spawned) || !spawned.agentId) {
288 void $.prompt.submit({ text: prepPrompt(occasion.trim(), language, CALL_THE_TOOL) })
289 $.ui.toast('Great Question: finding likely questions …')
290 return
291 }
292 prepAgent = spawned.agentId
293 ours.add(spawned.agentId)
294 const since = await $.clock.now()
295 await update($, preparing, () => ({ since, occasion: label.trim(), folder: cwd }))
296 stopTick?.cancel()
297 stopTick = $.clock.every(1000, () => void update($, tick, t => t + 1))
298 $.ui.toast('Great Question: finding likely questions in the background …')
299}
300
301/** The prepared questions from a background agent's reply; null when it is not the asked-for JSON or holds none. */
302export const fromReply = (reply: string, generatedAt: string, folder?: string): QuestionSet | null => {
303 try {
304 const set = toSet(JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1)) as Record<string, unknown>, generatedAt, folder)
305 return set.questions.length ? set : null
306 } catch {
307 return null
308 }
309}
310
311/** Stores prepared questions for this folder and shows them, the surest one open. */
312const showSet = async ($: EngineInterface, set: QuestionSet) => {
313 // Under the folder they were prepared in, which the session may have moved away from since.
314 await $.store.set(set.folder ? `set:${set.folder}` : await storeKey($), set)
315 await update($, current, () => set)
316 await update($, held, () => null)
317 await setOpen($, () => [set.questions[0]!.question])
318 await linkAll($, setLabels(set), set.folder)
319 await $.ui.open({ id: PANE, title: 'Great Question' })
320}
321
322const SOURCE_KINDS: Record<SourceKind, string> = {
323 meeting: 'Meetings & transcripts',
324 repo: 'Repository',
325 ticket: 'Issues & tickets',
326 doc: 'Documents',
327 calendar: 'Calendar',
328 other: 'Other',
329}
330
331// ponytail: keyword guess for plain-string sources (old sets, or a model that skips `kind`)
332const guessKind = (label: string): SourceKind =>
333 /wispr|transkript|meeting|termin \d|rehe?arsal/i.test(label) ? 'meeting'
334 : /kalender|calendar/i.test(label) ? 'calendar'
335 : /decision|issue|#\d|jira|pull request/i.test(label) ? 'ticket'
336 : /\.(md|html?|pdf|json|ts|tsx|py)\b|\//.test(label) ? 'repo'
337 : /drive|angebot|anhang|mail|rfp/i.test(label) ? 'doc'
338 : 'other'
339
340// Source kinds as stored by 0.8 and earlier.
341const OLD_KINDS: Record<string, SourceKind> = { kalender: 'calendar', andere: 'other' }
342
343const asSource = (src: unknown): Source =>
344 typeof src === 'object' && src !== null && 'label' in src
345 ? {
346 kind: (src as Source).kind in SOURCE_KINDS ? (src as Source).kind : (OLD_KINDS[String((src as Source).kind)] ?? guessKind(String((src as Source).label))),
347 label: String((src as Source).label),
348 }
349 : { kind: guessKind(String(src)), label: String(src) }
350
351const clamp = (n: unknown) => Math.min(5, Math.max(1, Math.round(Number(n) || 1)))
352
353const stamp = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ')
354
355const toSet = (input: Record<string, unknown>, generatedAt: string, folder?: string): QuestionSet => {
356 const raw = Array.isArray(input.questions) ? (input.questions as Question[]) : []
357 const questions = raw
358 .filter(q => typeof q?.question === 'string')
359 .map(q => ({
360 ...q,
361 who: asText(q.who),
362 why: asText(q.why),
363 answerer: asText(q.answerer),
364 relevance: clamp(q.relevance),
365 confidence: q.confidence === undefined ? undefined : clamp(q.confidence),
366 answers: toAnswers(q.answers),
367 }))
368 .sort(bySurest)
369
370 return {
371 title: String(input.title ?? 'Question set'),
372 language: input.language === 'en' || input.language === 'de' ? input.language : undefined,
373 mode: String(input.mode ?? ''),
374 generatedAt,
375 sources: Array.isArray(input.sources) ? input.sources.map(asSource) : [],
376 questions,
377 folder: folder ?? (typeof input.folder === 'string' ? input.folder : undefined),
378 recent: Array.isArray(input.recent)
379 ? (input.recent as Fact[]).filter(f => typeof f?.text === 'string').map(f => ({ text: f.text, source: asText(f.source) }))
380 : undefined,
381 }
382}
383
384/** Opening the first row freezes the live sections as they stand; closing the last lets them run again. */
385const setOpen = async ($: EngineInterface, change: (list: string[]) => string[]) => {
386 const list = await update($, opened, change)
387 const { next, added } = { ...IDLE, ...(await read($, live)) }
388 await update($, held, h => (list.length ? (h ?? { next, added }) : null))
389}
390
391const age = (ms: number) => (ms < 90_000 ? `${Math.round(ms / 1000)} s` : `${Math.round(ms / 60_000)} min`)
392
393/** The Wispr recording whose live transcript changed last, if it changed within FRESH_MS. */
394const runningRecording = async ($: EngineInterface, now: number) => {
395 const root = `${await $.env.get('HOME')}/Library/Application Support/Wispr Flow/meetings`
396 let best: { path: string; mtimeMs: number } | null = null
397 for (const entry of await $.fs.list(root)) {
398 if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue
399 const path = `${root}/${entry.name}/live.ndjson`
400 if (!(await $.fs.exists(path))) continue
401 const { mtimeMs } = await $.fs.stat(path)
402 if (!best || mtimeMs > best.mtimeMs) best = { path, mtimeMs }
403 }
404
405 return best && now - best.mtimeMs < FRESH_MS ? best.path : null
406}
407
408/** Lines in the session's repository that carry a question's words; none outside a git repository. */
409const repoHits = async ($: EngineInterface, terms: readonly string[]) => {
410 const { exitCode, stdout } = await $.process.run(grepArgs(terms), { timeoutMs: 5000 }).catch(() => ({ exitCode: 2, stdout: '' }))
411 return exitCode === 0 ? topHits(stdout, terms) : []
412}
413
414/** My name and latest commits in the folder the questions are about; nothing outside a git repository. */
415const myWork = async ($: EngineInterface, cwd: string) => {
416 try {
417 const me = parseIdent(await git($, ['var', 'GIT_AUTHOR_IDENT'], cwd))
418 return { name: me?.name ?? '', recent: me ? parseLog(await git($, logArgs(me.email), cwd)) : [] }
419 } catch {
420 return { name: '', recent: [] }
421 }
422}
423
424// Repository lookups by subagent: agent id to the question it looks up. A notification of one of them is dropped.
425const lookups = new Map<string, string>()
426const ours = new Set<string>()
427
428/** Starts a read-only subagent on the question asked now, unless the prepared answers already rest on sources. */
429const lookUp = async ($: EngineInterface, question: string, set: QuestionSet | null, ear: Live) => {
430 const prepared = set?.questions.find(q => q.question === question)
431 if (prepared && confidenceOf(prepared) >= 4) return
432 if (ear.looking.includes(question) || ear.deep[question]) return
433 // ponytail: spawn takes a model but no effort; a registered agent type per level if the lookup needs more thought
434 const { model } = await levelOf($)
435 const spawned = await $.agent
436 .spawn({ prompt: lookupPrompt(question, await read($, lang)), description: 'Great Question: repository lookup', subagentType: 'Explore', model })
437 .catch(() => null)
438 if (!spawned || !('agentId' in spawned) || !spawned.agentId) return
439 lookups.set(spawned.agentId, question)
440 ours.add(spawned.agentId)
441 await update($, live, l => ({ ...l, looking: [...l.looking, question] }))
442}
443
444/** One listening step: read the transcript's tail, let a small model match it against the prepared questions. */
445const listen = async ($: EngineInterface) => {
446 const state = { ...IDLE, ...(await read($, live)) }
447 const set = await read($, current)
448 if (!state.isOn) return
449 const now = await $.clock.now()
450 const path = await runningRecording($, now)
451 if (!path) {
452 await update($, live, l => ({ ...l, status: 'no running Wispr recording found' }))
453 return
454 }
455 const { lines, tail } = parseTranscript((await $.fs.read(path)) as string)
456 const base = state.meeting === path ? state : { ...IDLE, isOn: true, meeting: path }
457 // Written every step, so the age in the status line stays true while nobody speaks.
458 const reading = (l: Live) => `listening · ${lines} utterances · last utterance ${age(now - (l.changedAt || now))} ago`
459 if (lines === base.lines) {
460 await update($, live, l => (l.isOn ? { ...l, status: reading(l) } : l))
461 return
462 }
463 const me = await myWork($, set?.folder ?? (await $.session.cwd()))
464 const { model, effort, timeoutMs } = await levelOf($)
465 // ponytail: whole tail every 20 s while lines grow; a cheaper diff-only prompt if cost matters
466 const reply = await $.model.complete({
467 model,
468 effort,
469 system: SYSTEM,
470 prompt: [{ text: knowledge(set, { ...base, recent: me.recent }), cache: true }, { text: task(tail, await read($, lang), me.name) }],
471 // Up to six questions with three answers each: 2000 tokens cut the JSON short, and a cut reply changes nothing.
472 maxTokens: 4000,
473 timeoutMs,
474 })
475 if (!reply.isAnswered) {
476 await update($, live, l => ({ ...l, status: `model gave no answer (${reply.reason})` }))
477 return
478 }
479 const applied = applyReply(base, set, reply.text)
480 // Not the asked-for JSON: the lines stay unread, so the next step tries them again.
481 if (applied === base) {
482 await update($, live, l => ({ ...l, status: 'model reply was no JSON; trying again' }))
483 return
484 }
485 let step = { ...applied, lines, changedAt: now, recent: me.recent }
486 // New questions from this step: look their words up in the repository and let the model answer from the lines found.
487 const isNew = (q: Question, before: Question[]) => !before.some(b => b.question === q.question)
488 const fresh = [...step.added.filter(q => isNew(q, base.added)), ...step.next.filter(q => isNew(q, base.next))].filter(q => q.search?.length)
489 const items = (await Promise.all(fresh.map(async q => ({ question: q.question, hits: await repoHits($, q.search!) })))).filter(it => it.hits.length)
490 if (items.length) {
491 const grounded = await $.model.complete({ model, effort, system: GROUND_SYSTEM, prompt: [{ text: ground(items, await read($, lang)) }], maxTokens: 2000, timeoutMs })
492 if (grounded.isAnswered) step = applyGrounding(step, items.map(it => it.question), grounded.text)
493 }
494 // Keep what a lookup finished or started meanwhile; this step does not know about it.
495 await update($, live, l => (l.isOn ? { ...step, deep: l.deep, looking: l.looking, status: reading(step) } : l))
496 void linkAll($, answerSources([...step.added, ...step.next]))
497 if (step.current && step.current !== base.current) await lookUp($, step.current, set, { ...IDLE, ...(await read($, live)) })
498}
499
500const slug = (text: string) =>
501 text
502 .toLowerCase()
503 .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
504 .replace(/[^a-z0-9]+/g, '-')
505 .replace(/^-|-$/g, '')
506 .slice(0, 40)
507 .replace(/-$/, '')
508
509const mdQuestion = (q: Question) =>
510 [
511 `### ${q.question}`,
512 '',
513 ...[
514 q.who && `- Likely asked by: ${q.who}`,
515 q.why && `- Expected because: ${q.why}`,
516 q.answerer && `- Answered by us: ${q.answerer}`,
517 `- Likelihood ${q.relevance}/5 · Answer confidence ${confidenceOf(q)}/5`,
518 ].filter(Boolean),
519 '',
520 ...[...q.answers]
521 .sort((a, b) => ORDER.indexOf(a.risk) - ORDER.indexOf(b.risk))
522 .flatMap(a => [
523 `**${RISKS[a.risk].mark} ${RISKS[a.risk].action}** · ${RISKS[a.risk].label}`,
524 '',
525 a.text,
526 ...(a.note ? ['', `Note: ${a.note}`] : []),
527 ...(a.source ? ['', `Source: ${a.source}`] : []),
528 '',
529 ]),
530 ].join('\n')
531
532/** The pane as Markdown: the set by answer confidence, then whatever listening produced, then the sources. */
533export const toMarkdown = (set: QuestionSet | null, ear: Live, exportedAt: string, hrefs: Record<string, string> = {}) => {
534 const questions = [...(set?.questions ?? [])].sort(bySurest)
535 const section = (title: string, list: Question[]) => (list.length ? [`## ${title}`, '', ...list.map(mdQuestion)] : [])
536 const sources = (set?.sources ?? []).map(asSource)
537 const recent = recentOf(set, ear)
538
539 return [
540 `# ${set?.title ?? 'Questions from the conversation'}`,
541 '',
542 [set && `Prepared ${set.generatedAt} UTC`, `exported ${exportedAt} UTC`].filter(Boolean).join(' · '),
543 '',
544 `Legend: ${ORDER.map(r => `${RISKS[r].mark} ${RISKS[r].action.toLowerCase()} (${RISKS[r].label})`).join(' · ')}`,
545 '',
546 ...(recent.length ? ['## Safe to say: my recent work', '', ...recent.map(f => `- ${f.text}${f.source ? ` (${f.source})` : ''}`), ''] : []),
547 ...GROUPS.flatMap((g, k) =>
548 section(g.label, questions.filter(q => confidenceOf(q) >= g.min && (k === 0 || confidenceOf(q) < GROUPS[k - 1]!.min))),
549 ),
550 ...section('Asked in the meeting, not prepared', ear.added),
551 ...section('Likely next (latest state)', ear.next),
552 ...(ear.asked.length ? ['## Asked in the meeting', '', ...ear.asked.map(q => `- ${q}`), ''] : []),
553 ...(sources.length
554 ? [
555 '## Sources',
556 '',
557 ...(Object.keys(SOURCE_KINDS) as SourceKind[]).flatMap(kind => {
558 const items = sources.filter(src => src.kind === kind)
559 const line = (label: string) => (hrefs[label] ? `- [${label}](${hrefs[label]})` : `- ${label}`)
560 return items.length ? [`**${SOURCE_KINDS[kind]}**`, '', ...items.map(src => line(src.label)), ''] : []
561 }),
562 ]
563 : []),
564 ].join('\n')
565}
566
567const exportMarkdown = async ($: EngineInterface) => {
568 const set = await read($, current)
569 const ear = { ...IDLE, ...(await read($, live)) }
570 const now = stamp(await $.clock.now())
571 const path = `question-sets/${now.slice(0, 10)}-${now.slice(11, 16).replace(':', '')}-${slug(set?.title ?? 'conversation') || 'question-set'}.md`
572 // Absolute: a relative path is not reliably the session's working directory.
573 await $.fs.write(`${await $.session.cwd()}/${path}`, toMarkdown(set, ear, now, await read($, links)))
574 $.ui.toast(`Exported: ${path}`)
575}
576
577export const register: Register = on => {
578 let isListening = false
579 on('session.start', async ($, e, next) => {
580 await $.command.register({
581 name: 'great-question-prep',
582 description: 'Great Question: find the likely questions for a pitch, meeting or daily stand-up in the background (optionally name the occasion)',
583 argumentHint: '[occasion]',
584 })
585 await $.command.register({
586 name: 'great-question',
587 description: 'Great Question: open the pane (without preparing new questions)',
588 })
589 await $.tool.register({
590 name: 'show_questions',
591 description:
592 'Shows a question set (the most likely questions for a pitch, meeting or daily stand-up, with answer options by risk) in the Great Question pane and stores it for this repository.',
593 inputSchema: SCHEMA,
594 })
595 $.clock.every(LISTEN_MS, () => {
596 // Links a first try missed (git not ready, a source new since) come with the next tick.
597 void linkPending($)
598 if (isListening) return
599 isListening = true
600 void listen($).finally(() => (isListening = false))
601 })
602 // Once the session has settled: links of the questions it starts with.
603 $.clock.after(1000, () => void linkPending($))
604 // The version as installed, read from the copy that actually runs (the plugin cache, not the source folder).
605 try {
606 const manifest = JSON.parse((await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as string)
607 await update($, version, () => String(manifest.version ?? ''))
608 } catch {
609 // no manifest to read (tests): the pane just shows no version
610 }
611 // State and sets from 0.8 and earlier carry German names: translate them once here.
612 await update($, view, v => (v === 'practice' || (v as string) === 'training' ? 'practice' : 'meeting'))
613 await update($, sortBy, v => (v === 'likelihood' || (v as string) === 'wahrscheinlich' ? 'likelihood' : 'confidence'))
614 // Links are worked out again when drawn: one kept from a failed lookup would stay wrong.
615 await update($, links, () => ({}))
616 // Background agents do not outlive the module: nothing is still being looked up or prepared.
617 await update($, preparing, () => null)
618 // Answers 0.10.2 and earlier kept as the model wrote them: an object among them blanked the pane, so they are cleaned here too.
619 const cleaned = (deep: Live['deep'] = {}) => Object.fromEntries(Object.entries(deep).map(([q, list]) => [q, toAnswers(list)]))
620 await update($, live, l => ({ ...IDLE, ...l, added: withRisks(l.added ?? []), next: withRisks(l.next ?? []), deep: cleaned(l.deep), looking: [] }))
621 await update($, held, h => h && { next: withRisks(h.next), added: withRisks(h.added) })
622 // Questions on show from before 0.10 know no folder: the key they are stored under names it.
623 const shown = await read($, current)
624 if (shown && !shown.folder) {
625 for (const key of (await $.store.keys().catch(() => [])).filter(k => k.startsWith('set:'))) {
626 const kept = (await $.store.get(key)) as QuestionSet | undefined
627 if (kept?.title === shown.title && kept.generatedAt === shown.generatedAt) {
628 await update($, current, c => c && { ...c, folder: key.slice(4) })
629 break
630 }
631 }
632 }
633 const saved = (await $.store.get(await storeKey($))) as QuestionSet | undefined
634 if (saved) {
635 // Sets from before 0.10 carry no folder: the one they are stored under is it.
636 const set = toSet(saved as unknown as Record<string, unknown>, saved.generatedAt, saved.folder ?? (await $.session.cwd()))
637 await update($, current, () => set)
638 void $.ui.open({ id: PANE, title: 'Great Question' })
639 }
640
641 return next(e)
642 })
643
644 // A repository lookup ends as its subagent's turn: its answers replace the question's own while it is asked.
645 on('turn.complete', async ($, e, next) => {
646 const result = await next(e)
647 // The background agent that found likely questions: show them, or say nothing came back.
648 if (e.agentId && e.agentId === prepAgent) {
649 prepAgent = null
650 stopTick?.cancel()
651 stopTick = null
652 const folder = (await read($, preparing))?.folder
653 const set = e.reason === 'answer' ? fromReply(e.answer, stamp(await $.clock.now()), folder) : null
654 await update($, preparing, () => null)
655 if (set) await showSet($, set)
656 $.ui.toast(set ? `Great Question: ${set.questions.length} likely questions ready` : 'Great Question: no likely questions came back')
657 return result
658 }
659 const question = e.agentId ? lookups.get(e.agentId) : undefined
660 if (!question) return result
661 lookups.delete(e.agentId!)
662 await update($, live, l => settleLookup({ ...IDLE, ...l }, question, e.reason === 'answer' ? e.answer : null))
663 void linkAll($, ((await read($, live)).deep?.[question] ?? []).map(a => a.source))
664 return result
665 })
666
667 // The notification of a finished lookup would start a turn of the main model mid-meeting: it stays out.
668 on('prompt.submit', async ($, e, next) => {
669 if (e.origin?.kind === 'task-notification' && [...ours].some(id => e.text.includes(id))) return { drop: 'Great Question: repository lookup done' }
670 return next(e)
671 })
672
673 on('command.run', { command: 'great-question' }, async $ => {
674 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Great Question' })
675
676 return { text: isPlaced ? 'Great Question opened.' : 'Great Question opened but not shown: make the window wider.' }
677 })
678
679 on('command.run', { command: 'great-question-prep' }, async ($, e) => {
680 await $.ui.open({ id: PANE, title: 'Great Question' })
681 await startPrep($, e.args)
682
683 return { text: `Finding likely questions in the background${e.args.trim() ? ` (occasion: ${e.args.trim()})` : ' (no occasion given)'}.` }
684 })
685
686 on('tool.call', { tool: TOOL }, async ($, e) => {
687 const set = toSet(e as unknown as Record<string, unknown>, stamp(await $.clock.now()), await $.session.cwd())
688 if (set.questions.length === 0) return { deny: 'No questions in the input: give questions[] with question, relevance, answers.' }
689 await showSet($, set)
690
691 return { result: `${set.questions.length} questions shown in the Great Question pane.` }
692 })
693
694 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
695 const { Box, Text, Button, Link } = $.ui.resolve(e)
696 const stored = await read($, current)
697 const hrefs = await read($, links)
698 // A source as a link where it points somewhere a surface can open, plain text otherwise.
699 const sourceOf = (label: string, prefix: string, key: string) => {
700 const href = hrefs[label]
701 return href ? (
702 <Box key={key} flexDirection="row" columnGap={1}>
703 <Text dimColor>{prefix}</Text>
704 <Link href={href} label={label} />
705 </Box>
706 ) : (
707 <Text key={key} dimColor>
708 {prefix} {label}
709 </Text>
710 )
711 }
712 const openList = await read($, opened)
713 const frozen = await read($, held)
714 const isMore = await read($, more)
715 const folder = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? 'this folder'
716 const prep = await read($, preparing)
717 const frame = ['◐', '◓', '◑', '◒'][(await read($, tick)) % 4]
718 const took = prep ? Math.max(0, Math.round(((await $.clock.now()) - prep.since) / 1000)) : 0
719 const isPractice = (await read($, view)) === 'practice'
720 const isByLikely = (await read($, sortBy)) === 'likelihood'
721 const language = await read($, lang)
722 const level = await read($, quality)
723 // Spread over IDLE: a value from an older version of the mod may lack newer fields.
724 const ear: Live = { ...IDLE, ...(await read($, live)) }
725 const shown = await read($, version)
726 const recent = recentOf(stored, ear)
727 const hasLive = ear.asked.length + ear.added.length + ear.next.length > 0
728
729 // Without a set the pane still listens; it just has nothing prepared to match.
730 const set: QuestionSet = stored ?? { title: 'No prepared questions yet', mode: '', generatedAt: '', sources: [], questions: [] }
731
732 // A dim rule above each section header; truncated so it never wraps on a narrow or proportional surface.
733 const rule = '─'.repeat(Math.max(8, Number(e.props.bodyColumns) || 60))
734 const heading = (text: string) => (
735 <>
736 <Text dimColor wrap="truncate">{rule}</Text>
737 <Text bold dimColor>{text}</Text>
738 </>
739 )
740 const isOpen = (q: Question) => openList.includes(q.question)
741 const toggle = (q: Question) => setOpen($, list => (list.includes(q.question) ? list.filter(t => t !== q.question) : [...list, q.question]))
742 const answerRow = (a: Answer, j: number) => (
743 <Box key={`a${j}`} flexDirection="column" marginTop={1}>
744 <Text>
745 <Text color={RISKS[a.risk].color} bold>
746 {RISKS[a.risk].mark} {RISKS[a.risk].action}
747 </Text>
748 <Text dimColor> · {RISKS[a.risk].label}</Text>
749 </Text>
750 <Box flexDirection="column" paddingLeft={2}>
751 {/* Colour repeats the mark and the word above, so nothing rests on colour alone. */}
752 <Text color={RISKS[a.risk].color}>{a.text}</Text>
753 {a.note && <Text dimColor>Note: {a.note}</Text>}
754 {a.source && sourceOf(a.source, 'Source:', `src${j}`)}
755 </Box>
756 </Box>
757 )
758 // A repository lookup's answers stand in for the question's own.
759 const byRisk = (q: Question) => [...(ear.deep[q.question] ?? q.answers)].sort((a, b) => ORDER.indexOf(a.risk) - ORDER.indexOf(b.risk))
760 const isAsked = (q: Question) => ear.asked.includes(q.question)
761 // Live rows show their best answer when closed, so a glance is enough and nothing opens by itself.
762 const questionRow = (q: Question, hasHint = false) => {
763 const k = rowKey(q.question)
764 const best = byRisk(q)[0]
765 return (
766 <Box key={`r${k}`} flexDirection="column" marginBottom={1}>
767 <Button key={`t${k}`} plain onPress={() => toggle(q)}>
768 {isOpen(q) ? '▾ ' : '▸ '}
769 {isAsked(q) ? <Text dimColor>✓ </Text> : ''}
770 <Text bold color={isAsked(q) ? undefined : 'text'} dimColor={isAsked(q)}>
771 {q.question}
772 </Text>{' '}
773 {byRisk(q).map(a => <Text color={RISKS[a.risk].color}>{RISKS[a.risk].mark}</Text>)}
774 </Button>
775 {!isOpen(q) && hasHint && best && (
776 // The mark in a box of its own that never shrinks: inside one truncated line the desktop broke it apart.
777 <Box paddingLeft={2} flexDirection="row" columnGap={1}>
778 <Box flexShrink={0}>
779 <Text color={RISKS[best.risk].color}>
780 {RISKS[best.risk].mark} {RISKS[best.risk].action}:
781 </Text>
782 </Box>
783 <Box flexShrink={1} minWidth={0}>
784 <Text dimColor wrap="truncate">
785 {best.text}
786 </Text>
787 </Box>
788 </Box>
789 )}
790 {isOpen(q) && (
791 <Box flexDirection="column" paddingLeft={2}>
792 {/* The question leads; these lines step back as far as the theme allows. */}
793 {q.who && <Text color="subtle" dimColor italic>Likely asked by: {q.who}</Text>}
794 {q.why && <Text color="subtle" dimColor italic>Expected because: {q.why}</Text>}
795 {q.answerer && <Text color="subtle" dimColor italic>Answered by us: {q.answerer}</Text>}
796 <Text color="subtle" dimColor italic>
797 Likelihood {q.relevance}/5 · Answer confidence {confidenceOf(q)}/5
798 </Text>
799 {byRisk(q).map(answerRow)}
800 </Box>
801 )}
802 </Box>
803 )
804 }
805 const order = isPractice ? -1 : 1
806 const groupKey = isByLikely ? (q: Question) => q.relevance : confidenceOf
807 const groupList = isByLikely ? LIKELY_GROUPS : GROUPS
808 // While listening an asked question stays where it is, marked ✓; paused, it moves to the end.
809 const isSinking = (q: Question) => !ear.isOn && isAsked(q)
810 const listed = groupList
811 .map((g, k) => ({
812 ...g,
813 items: set.questions
814 .filter(q => !isSinking(q))
815 .filter(q => groupKey(q) >= g.min && (k === 0 || groupKey(q) < groupList[k - 1]!.min))
816 .sort((a, b) => order * (groupKey(b) - groupKey(a) || b.relevance - a.relevance || confidenceOf(b) - confidenceOf(a))),
817 }))
818 .filter(g => g.items.length > 0)
819 .sort((a, b) => order * (b.min - a.min))
820 const asked = set.questions.filter(isSinking)
821 // The live sections as drawn: held while a row is open, so nothing moves under the reader.
822 const source = frozen ?? ear
823 const inSet = (q: Question) => set.questions.some(k => k.question === q.question)
824 const nextRows = source.next.filter(q => !inSet(q))
825 const addedRows = source.added.filter(q => !inSet(q) && !nextRows.some(k => k.question === q.question))
826 const drawn = new Set([...nextRows, ...addedRows].map(q => q.question))
827 const waiting = frozen ? [...ear.next, ...ear.added].filter(q => !drawn.has(q.question) && !inSet(q)).length : 0
828 // The question on the table, always live; its row stays in its section too.
829 const nowRow = ear.current ? [...set.questions, ...ear.added].find(q => q.question === ear.current) : undefined
830 const rows = [...nextRows, ...addedRows, ...listed.flatMap(g => g.items), ...asked]
831 // Two buttons, not one toggle: closing must never mean opening every row first.
832 const isAllOpen = rows.length > 0 && rows.every(isOpen)
833 const isAnyOpen = rows.some(isOpen)
834 const sorting = isPractice
835 ? `Practice: ${isByLikely ? 'rarest' : 'least certain'} first`
836 : isByLikely ? 'most likely first' : 'surest first'
837
838 return (
839 <Box flexDirection="column" gap={1}>
840 <Box flexDirection="column">
841 <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
842 <Text bold>{set.title}</Text>
843 {shown && <Text dimColor>v{shown}</Text>}
844 </Box>
845 <Text dimColor>
846 {stored ? `${set.questions.length} questions${set.language ? ` · ${set.language.toUpperCase()}` : ''}` : 'Find likely questions or listen live'} · {sorting}
847 </Text>
848 </Box>
849 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
850 {ORDER.map(r => (
851 <Text key={r} color={RISKS[r].color}>
852 {RISKS[r].mark} {RISKS[r].action.toLowerCase()}
853 </Text>
854 ))}
855 </Box>
856 {/* Wraps onto further rows on a narrow pane instead of pushing buttons out of view. */}
857 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
858 <Button
859 key="ear"
860 variant={ear.isOn ? 'primary' : 'secondary'}
861 label={ear.isOn ? '◉ Listening' : 'Listen live'}
862 // Stopping only pauses: what the call produced stays until Clear; a new recording starts afresh.
863 onPress={() =>
864 update($, live, l => ({
865 ...l,
866 isOn: !l.isOn,
867 status: l.isOn ? `paused · ${l.lines} utterances read · results stay` : 'starting …',
868 }))
869 }
870 />
871 <Button key="gen" label={prep ? 'Finding …' : 'Find likely questions'} onPress={() => startPrep($, '')} />
872 {rows.length > 0 && !isAllOpen && <Button key="open-all" label="Open all" onPress={() => setOpen($, () => rows.map(q => q.question))} />}
873 {isAnyOpen && <Button key="close-all" label="Close all" onPress={() => setOpen($, () => [])} />}
874 <Button key="more" dimColor label={isMore ? 'More ▾' : 'More ▸'} onPress={() => update($, more, m => !m)} />
875 </Box>
876 {isMore && (
877 <Box flexDirection="column" rowGap={1}>
878 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
879 <Button
880 key="lang"
881 label={`Language: ${language === 'auto' ? 'Auto' : language.toUpperCase()}`}
882 onPress={() => update($, lang, l => LANGS[(LANGS.indexOf(l) + 1) % LANGS.length]!)}
883 />
884 <Button
885 key="quality"
886 label={`Model: ${QUALITY[level]?.label ?? QUALITY.fast.label}`}
887 onPress={() => update($, quality, q => QUALITIES[(QUALITIES.indexOf(q) + 1) % QUALITIES.length]!)}
888 />
889 <Button
890 key="sort"
891 label={isByLikely ? 'Sort: most likely asked' : 'Sort: safest to answer'}
892 onPress={() => update($, sortBy, v => (v === 'likelihood' ? 'confidence' : 'likelihood'))}
893 />
894 <Button
895 key="view"
896 variant={isPractice ? 'primary' : 'secondary'}
897 label={isPractice ? 'Practice' : 'Meeting'}
898 onPress={() => update($, view, v => (v === 'practice' ? 'meeting' : 'practice'))}
899 />
900 {(stored || hasLive) && <Button key="export" label="Export" onPress={() => exportMarkdown($)} />}
901 {!ear.isOn && hasLive && (
902 <Button
903 key="clear"
904 label="Clear"
905 onPress={async () => {
906 await update($, live, () => IDLE)
907 await update($, held, h => h && { next: [], added: [] })
908 }}
909 />
910 )}
911 </Box>
912 {stored && <Text dimColor>Prepared {set.generatedAt} UTC</Text>}
913 </Box>
914 )}
915 {prep && (
916 <Box key="prep" flexDirection="column">
917 <Text color="warning">
918 {frame} Finding likely questions{prep.occasion ? ` for ${prep.occasion}` : ''} · {Math.floor(took / 60)}:{String(took % 60).padStart(2, '0')}
919 </Text>
920 <Text dimColor>In the background, from the repository and connected sources; the session stays free.</Text>
921 </Box>
922 )}
923 {ear.status && <Text dimColor>{ear.status}</Text>}
924 {(ear.isOn || nowRow) && (
925 <Box key="now" flexDirection="column" borderStyle="round" paddingX={1}>
926 <Text inverse bold> ASKED NOW </Text>
927 {nowRow ? (
928 <>
929 <Box marginTop={1}>
930 <Text bold>{nowRow.question}</Text>
931 </Box>
932 {ear.looking.includes(nowRow.question) && <Text dimColor>checking the repository …</Text>}
933 {ear.deep[nowRow.question] && <Text dimColor>from the repository</Text>}
934 {byRisk(nowRow).map(answerRow)}
935 </>
936 ) : (
937 <Text dimColor>no question recognised yet</Text>
938 )}
939 </Box>
940 )}
941 {/* True whatever is asked: what I worked on last, to fall back on when no answer above fits. */}
942 {recent.length > 0 && (
943 <Box key="safe" flexDirection="column">
944 {heading(`SAFE TO SAY · MY RECENT WORK · ${recent.length}`)}
945 {recent.map((f, j) => (
946 <Box key={`f${j}`} flexDirection="column" marginTop={1}>
947 <Text color="success">● {f.text}</Text>
948 {f.source && <Box paddingLeft={2}>{sourceOf(f.source, 'Source:', `fs${j}`)}</Box>}
949 </Box>
950 ))}
951 </Box>
952 )}
953 {waiting > 0 && (
954 <Button
955 key="sync"
956 variant="primary"
957 label={`↻ take ${waiting} new`}
958 onPress={() => update($, held, () => ({ next: ear.next, added: ear.added }))}
959 />
960 )}
961 {nextRows.length > 0 && (
962 <Box key="next" flexDirection="column">
963 {heading(`LIKELY NEXT · ${nextRows.length}`)}
964 {nextRows.map(q => questionRow(q, true))}
965 </Box>
966 )}
967 {addedRows.length > 0 && (
968 <Box key="new" flexDirection="column">
969 {heading(`ASKED IN THE MEETING, NOT PREPARED · ${addedRows.length}`)}
970 {addedRows.map(q => questionRow(q, true))}
971 </Box>
972 )}
973 {listed.map(g => (
974 <Box key={g.label} flexDirection="column">
975 {heading(`${g.label.toUpperCase()} · ${g.items.length}`)}
976 {g.items.map(q => questionRow(q))}
977 </Box>
978 ))}
979 {asked.length > 0 && (
980 <Box key="asked" flexDirection="column">
981 {heading(`ALREADY ASKED · ${asked.length}`)}
982 {asked.map(q => questionRow(q))}
983 </Box>
984 )}
985 {/* Nothing prepared, nothing heard: offer where to start instead of an empty pane. */}
986 {!stored && !prep && !ear.isOn && !hasLive && (
987 <Box key="welcome" flexDirection="column" borderStyle="round" paddingX={1} rowGap={1}>
988 <Text bold>Where do you want to start?</Text>
989 <Box flexDirection="column">
990 <Button
991 key="start-repo"
992 plain
993 label={`▸ Likely questions about ${folder}`}
994 onPress={() =>
995 startPrep($, `questions about this repository (${folder}): what it does, its current state, decisions, risks and open points`, folder)
996 }
997 />
998 <Button
999 key="start-meeting"
1000 plain
1001 label="▸ Prepare my next meeting"
1002 onPress={() =>
1003 startPrep($, 'my next meeting in the calendar: find it with list_upcoming_meetings, its attendees and earlier meetings with them', 'my next meeting')
1004 }
1005 />
1006 <Button key="start-daily" plain label="▸ Prepare today's daily" onPress={() => startPrep($, "today's daily stand-up", "today's daily")} />
1007 </Box>
1008 <Text dimColor>Or just Listen live: questions then come from the live transcript only, answers from this repository.</Text>
1009 </Box>
1010 )}
1011 {set.sources.length > 0 && (
1012 <Box key="sources" flexDirection="column">
1013 {heading('SOURCES')}
1014 {(Object.keys(SOURCE_KINDS) as SourceKind[]).map(kind => {
1015 const items = set.sources.map(asSource).filter(src => src.kind === kind)
1016 return items.length === 0 ? null : (
1017 <Box key={`k${kind}`} flexDirection="column" marginTop={1}>
1018 <Text dimColor underline>{SOURCE_KINDS[kind]}</Text>
1019 {items.map((src, k) => sourceOf(src.label, '•', `s${kind}${k}`))}
1020 </Box>
1021 )
1022 })}
1023 </Box>
1024 )}
1025 </Box>
1026 )
1027 })
1028}
1029hooks/live.ts 323 lines1import type { Answer, Fact, Lang, Live, Question, QuestionSet, Risk } from '../types'
2
3export const IDLE: Live = { isOn: false, status: '', meeting: null, lines: 0, changedAt: 0, asked: [], current: null, added: [], next: [], deep: {}, looking: [], recent: [] }
4
5const RISKS: readonly Risk[] = ['sourced', 'commitment', 'vague', 'risky']
6// Risk classes as stored by 0.8 and earlier, which used German names.
7const OLD_RISKS: Record<string, Risk> = { belegt: 'sourced', zusage: 'commitment', vage: 'vague', riskant: 'risky' }
8
9/** A risk class from a model reply or an older stored set; anything unknown counts as vague. */
10export const asRisk = (risk: unknown): Risk =>
11 RISKS.includes(risk as Risk) ? (risk as Risk) : (OLD_RISKS[String(risk)] ?? 'vague')
12
13/** Questions from state an older version wrote: their risk classes in today's names, their notes and sources text or nothing. */
14export const withRisks = (list: Question[]): Question[] => list.map(q => ({ ...q, answers: toAnswers(q.answers) }))
15
16/** A model's free text as the pane draws it: a string, or nothing. An object drawn as text makes the engine refuse every redraw. */
17export const asText = (v: unknown) => (typeof v === 'string' ? v : undefined)
18
19const WINDOW_MS = 120_000
20const MAX_LINES = 60
21const MAX_NEXT = 5
22
23// A line without a source is nobody in particular: labelled Mic, every question in it read as mine and was left out.
24const SPEAKERS: Record<string, string> = { system: 'Call', mic: 'Mic' }
25
26type Utterance = { text?: string; timestamp?: string; endRecordingMs?: number; speaker?: { source?: string } }
27
28/**
29 * Wispr's live.ndjson: a meta line, then one utterance per line. The tail is the last two minutes of the
30 * recording. Names stay out; `system` is call audio, `mic` this machine's microphone (us, or a whole room).
31 */
32export const parseTranscript = (ndjson: string) => {
33 const rows = ndjson
34 .split('\n')
35 .map(line => {
36 try {
37 return JSON.parse(line) as Utterance
38 } catch {
39 return null
40 }
41 })
42 .filter((u): u is Utterance => typeof u?.text === 'string')
43 const end = Number(rows.at(-1)?.endRecordingMs) || 0
44 const recent = rows.filter(u => !end || Number(u.endRecordingMs) >= end - WINDOW_MS).slice(-MAX_LINES)
45
46 return {
47 lines: rows.length,
48 tail: recent
49 .map(u => `[${u.timestamp ?? ''}] ${SPEAKERS[u.speaker?.source ?? ''] ?? 'Unknown'}: ${u.text}`)
50 .join('\n'),
51 }
52}
53
54/** A row's address, from its text: focus and open state stay with the question when rows around it change. */
55export const rowKey = (question: string) => {
56 let h = 5381
57 for (const c of question) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0
58 return `q:${h.toString(36)}`
59}
60
61const words = (text: string) =>
62 new Set(
63 text
64 .toLowerCase()
65 .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
66 .split(/[^a-z0-9]+/)
67 .filter(w => w.length > 2),
68 )
69
70// ponytail: word overlap catches the model rewording a question between steps; embeddings if it misses too much
71export const isSameQuestion = (a: string, b: string) => {
72 if (a.toLowerCase() === b.toLowerCase()) return true
73 const x = words(a)
74 const y = words(b)
75 const shared = [...x].filter(w => y.has(w)).length
76 return x.size > 0 && y.size > 0 && shared / (x.size + y.size - shared) >= 0.7
77}
78
79export const SYSTEM =
80 'You are listening to a meeting in progress and help me anticipate the questions I will have to answer, and answer them safely. Reply with a JSON object only, no text before or after it.'
81
82/** What I worked on last: what the prepared set says, else my latest commits found while listening. */
83export const recentOf = (set: QuestionSet | null, live: Live): Fact[] => (set?.recent?.length ? set.recent : (live.recent ?? []))
84
85export const knowledge = (set: QuestionSet | null, live: Live) =>
86 [
87 set && set.questions.length
88 ? [
89 'Prepared question set with sourced answers (number: question | answers with risk):',
90 ...set.questions.map(
91 (q, i) =>
92 `${i}: ${q.question} | ${q.answers.map(a => `[${a.risk}] ${a.text}${a.source ? ` (source: ${a.source})` : ''}`).join(' / ')}`,
93 ),
94 ].join('\n')
95 : 'No prepared question set.',
96 '',
97 'Questions already asked in the meeting that are not in the set:',
98 ...(live.added.length ? live.added.map(q => `- ${q.question}`) : ['- none']),
99 '',
100 'What I worked on most recently, each certainly true:',
101 ...recentOf(set, live).map(f => `- ${f.text}${f.source ? ` (source: ${f.source})` : ''}`),
102 ].join('\n')
103
104/** Questions and answers in the meeting's language; who, why and notes stay English for the team. */
105export const languageRule = (lang: Lang) =>
106 lang === 'de'
107 ? 'Write "question" and the answer "text"s in German.'
108 : lang === 'en'
109 ? 'Write "question" and the answer "text"s in English.'
110 : 'Write "question" and the answer "text"s in the language the meeting is held in.'
111
112/**
113 * The answer options every live prompt asks for, safest first: one that is true and binds us to nothing always comes,
114 * a promise only when the question asks for one. `sourced` says what counts as a source in that prompt.
115 */
116const answerRules = (sourced: string) => `- "sourced": ${sourced}
117- "vague": true and binding us to nothing: what we know, what is still open. When nothing is sourced, the first option is this one.
118- "commitment": only when the question asks for a date, an owner or a scope; never a date, name or promise nobody gave in the meeting or the material.
119- "risky": at most one, as a warning of what not to say.
120Only a "commitment" may promise anything: no "I will send …", "we will check …" or date in any other option.
121Every option but "sourced" has a "note".`
122
123export const task = (tail: string, lang: Lang = 'auto', me = '') => `The last two minutes of the transcript. I am the person this pane is for${me ? `: ${me}` : ''}. "Mic" is this computer's microphone: me in a video call, everyone in the room in an on-site meeting. "Call" is audio from the video call: everyone else. "Unknown" is anyone. Decide who asks whom from the content, not from the label alone.
124${tail}
125
126Task:
1271. "asked": numbers of the questions from the question set that were asked, in substance, in this excerpt.
1282. "new": questions asked in this excerpt that I have to answer or should be ready to answer (asked of me or of everyone, or about something I work on), neither in the question set nor among those already asked (at most 3). A question to "you", to your team or to a role such as the project lead is asked of me. Leave out only questions addressed by name to someone else about their own work.
1293. "current": the question from the set or from "new" that was asked last in this excerpt: {"set": number} or {"new": index in "new"}; null only when the excerpt asks none.
1304. "next": 2 to 3 questions I will most likely have to answer next, given how the conversation is going and what I worked on recently, also outside the set.
1315. For every question in "new" and "next": "search", 2 to 4 distinctive words likely to appear in our repository where the answer would be written (product and component names, file names, figures), in the language the repository is most likely written in.
132Language: ${languageRule(lang)} "why" and "note" in English.
133For every question in "new" and "next": "relevance" (1 to 5, how likely I am the one to answer it), "confidence" (1 to 5, how well the material above supports a sourced answer), "why" (short, tied to the conversation) and 2 to 3 answer options, safest first, each with "risk":
134${answerRules('only when the question set or my recent work carries the answer in substance; then "source" as there.')}
135Format: {"asked": [], "new": [], "current": null, "next": [{"question": "", "relevance": 3, "confidence": 2, "why": "", "search": [""], "answers": [{"text": "", "risk": "vague", "note": ""}]}]}`
136
137const clamp = (n: unknown) => Math.min(5, Math.max(1, Math.round(Number(n) || 1)))
138
139/** Answer options from a model reply, a prepared set or older state; every caller's answers pass here before they are drawn. */
140export const toAnswers = (raw: unknown): Answer[] =>
141 (Array.isArray(raw) ? (raw as Answer[]) : [])
142 .filter(a => typeof a?.text === 'string')
143 .map(a => ({ text: a.text, risk: asRisk(a.risk), note: asText(a.note), source: asText(a.source) }))
144
145const toQuestions = (raw: unknown, limit: number): Question[] =>
146 (Array.isArray(raw) ? (raw as Question[]) : [])
147 .filter(q => typeof q?.question === 'string')
148 .slice(0, limit)
149 .map(q => ({
150 question: q.question,
151 relevance: clamp(q.relevance),
152 confidence: clamp(q.confidence),
153 why: asText(q.why),
154 search: Array.isArray(q.search) ? q.search.filter(t => typeof t === 'string' && t.trim()).slice(0, 4) : undefined,
155 answers: toAnswers(q.answers),
156 }))
157
158/**
159 * Folds one model reply into the live state; a reply that is not the asked-for JSON leaves the state as it was.
160 * The catalogue only grows: new rows go to the end, a row keeps its wording, nothing is dropped but a prediction.
161 */
162export const applyReply = (live: Live, set: QuestionSet | null, reply: string): Live => {
163 let data: { asked?: unknown; new?: unknown; current?: { set?: unknown; new?: unknown } | null; next?: unknown }
164 try {
165 data = JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1))
166 } catch {
167 return live
168 }
169 const questions = set?.questions ?? []
170 const isKnown = (q: string, list: Question[]) => list.some(k => isSameQuestion(k.question, q))
171
172 const fresh = toQuestions(data.new, 3)
173 const added = [...live.added]
174 for (const q of fresh) if (!isKnown(q.question, [...questions, ...added])) added.push(q)
175
176 const asked = new Set(live.asked)
177 for (const n of Array.isArray(data.asked) ? data.asked : []) {
178 const q = questions[Number(n)]
179 if (q) asked.add(q.question)
180 }
181
182 const pick = data.current
183 // A new question matched to one already listed takes that row's wording, so it points at a row on screen.
184 const asListed = (q: string) => [...questions, ...added].find(k => isSameQuestion(k.question, q))?.question ?? q
185 const picked =
186 (pick && typeof pick.set === 'number' && questions[pick.set]?.question) ||
187 (pick && typeof pick.new === 'number' && fresh[pick.new]?.question) ||
188 null
189 // Without a usable pick, the question this reply saw asked last stands in: keeping the old one left it on screen for good.
190 const seen = [...added.slice(live.added.length).map(q => q.question), ...[...asked].filter(q => !live.asked.includes(q))].at(-1)
191 const current = picked ? asListed(picked) : (seen ?? live.current)
192 if (current) asked.add(current)
193
194 // Predictions stay until asked; new ones join at the end, and past MAX_NEXT the oldest not predicted again goes.
195 const isAsked = (q: string) => [...asked].some(a => isSameQuestion(a, q)) || isKnown(q, added)
196 const predicted = toQuestions(data.next, 3).filter(q => !isAsked(q.question) && !isKnown(q.question, questions))
197 const next = live.next.filter(q => !isAsked(q.question))
198 const confirmed = new Set(next.filter(q => isKnown(q.question, predicted)).map(q => q.question))
199 for (const q of predicted) if (!isKnown(q.question, next)) next.push(q)
200 while (next.length > MAX_NEXT) {
201 const stale = next.findIndex(q => !confirmed.has(q.question))
202 next.splice(stale === -1 ? 0 : stale, 1)
203 }
204
205 return { ...live, asked: [...asked], current, added, next }
206}
207
208const MAX_HITS = 8
209
210/** git grep for a question's words in the session's repository; this mod's exports stay out, they hold earlier answers. */
211export const grepArgs = (terms: readonly string[]) => [
212 'git', 'grep', '-n', '-I', '-i', '-F', '--max-count=3',
213 ...terms.flatMap(t => ['-e', t]),
214 '--', '.', ':(exclude)question-sets', ':(exclude)fragensets',
215]
216
217/** The matching lines as `path:line: text`, files that hit the most words first, cut so a prompt stays small. */
218export const topHits = (stdout: string, terms: readonly string[]) => {
219 const byFile = new Map<string, string[]>()
220 for (const line of stdout.split('\n')) {
221 const m = /^([^:]+):(\d+):(.*)$/.exec(line)
222 if (!m) continue
223 byFile.set(m[1]!, [...(byFile.get(m[1]!) ?? []), `${m[1]}:${m[2]}: ${m[3]!.trim().slice(0, 200)}`])
224 }
225 const words = (lines: string[]) => terms.filter(t => lines.some(l => l.toLowerCase().includes(t.toLowerCase()))).length
226 return [...byFile.values()].sort((a, b) => words(b) - words(a)).flat().slice(0, MAX_HITS)
227}
228
229export const GROUND_SYSTEM = 'You answer questions from a meeting with lines from our repository. Reply with a JSON object only, no text before or after it.'
230
231export const ground = (items: readonly { question: string; hits: string[] }[], lang: Lang) => `Questions from the meeting, each with lines from our repository (path:line: text):
232
233${items.map((it, i) => `${i}: ${it.question}\n${it.hits.join('\n')}`).join('\n\n')}
234
235For every question, 2 to 3 answer options, safest first, each with "risk":
236${answerRules('only when the lines above carry the answer; "source" is the path:line it rests on.')}
237Language: ${languageRule(lang)} "note" in English.
238Format: {"answers": {"0": [{"text": "", "risk": "sourced", "source": "path:12", "note": ""}]}}, keyed by the question's number; leave out a question you cannot answer.`
239
240const parse = (reply: string) => JSON.parse(reply.slice(reply.indexOf('{'), reply.lastIndexOf('}') + 1)) as { answers?: unknown }
241
242/** Puts the answers a repository search grounded in place of a live question's own; a sourced one lifts its confidence. */
243export const applyGrounding = (live: Live, questions: readonly string[], reply: string): Live => {
244 let lists: Record<string, unknown>
245 try {
246 const raw = parse(reply).answers
247 // A list by position only counts when it holds one entry per question: a short one would shift answers onto the wrong row.
248 lists = Array.isArray(raw) ? (raw.length === questions.length ? { ...raw } : {}) : raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
249 } catch {
250 return live
251 }
252 const found = new Map(questions.map((q, i) => [q, toAnswers(lists[i])] as const).filter(([, a]) => a.length > 0))
253 const swap = (q: Question): Question => {
254 const answers = found.get(q.question)
255 if (!answers) return q
256 const isSourced = answers.some(a => a.risk === 'sourced')
257 return { ...q, answers, confidence: isSourced ? Math.max(q.confidence ?? 1, 4) : q.confidence }
258 }
259 return { ...live, added: live.added.map(swap), next: live.next.map(swap) }
260}
261
262/** The task for a read-only subagent that looks a question up in the repository while the meeting goes on. */
263export const lookupPrompt = (question: string, lang: Lang) => `Someone in a meeting just asked us: "${question}"
264Find the answer in this repository: search with Grep and Glob, then read what you find. Change nothing.
265Reply with a JSON object only: {"answers": [{"text": "", "risk": "sourced", "source": "path:line", "note": ""}]} with 1 to 3 answers, safest first, each with "risk":
266${answerRules('only for an answer a file you read carries; "source" is its path:line.')}
267${languageRule(lang)} "note" in English.`
268
269/** The answers in a subagent's reply; nothing when it is not the asked-for JSON. */
270export const parseAnswers = (reply: string): Answer[] => {
271 try {
272 return toAnswers(parse(reply).answers)
273 } catch {
274 return []
275 }
276}
277
278/** A finished lookup: no longer running, and its answers, if any, stand in for the question's own. */
279export const settleLookup = (live: Live, question: string, reply: string | null): Live => {
280 const answers = reply === null ? [] : parseAnswers(reply)
281 return {
282 ...live,
283 looking: live.looking.filter(q => q !== question),
284 deep: answers.length ? { ...live.deep, [question]: answers } : live.deep,
285 }
286}
287
288/** Name and email from `git var GIT_AUTHOR_IDENT`; null when the output is not that. */
289export const parseIdent = (out: string | null) => {
290 const m = /^(.+?) <([^>]+)>/.exec(out ?? '')
291 return m ? { name: m[1]!, email: m[2]! } : null
292}
293
294/** My last five commits on any branch, merges left out: what I worked on last, whatever the meeting asks. */
295export const logArgs = (email: string) => ['log', '--branches', '--remotes', '--no-merges', '-F', `--author=${email}`, '-n', '5', '--format=%h%x09%as%x09%s']
296
297export const parseLog = (out: string | null): Fact[] =>
298 (out ?? '')
299 .split('\n')
300 .map(line => line.split('\t'))
301 .filter(p => p.length >= 3 && p.slice(2).join('\t').trim())
302 .map(([sha, date, ...subject]) => ({ text: subject.join('\t').trim(), source: `commit ${sha}, ${date}` }))
303
304/** GitHub's https address of a repository from its origin remote; null for any other remote. */
305export const githubBase = (remote: string | null) => {
306 const m = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(remote ?? '')
307 return m ? `https://github.com/${m[1]}/${m[2]}` : null
308}
309
310/** What a source label points at: a URL, an issue in a named or the session's repository, or a file with its line. */
311export type SourceRef = { url: string } | { repo: string; issue: string } | { issue: string } | { path: string; line?: string }
312
313export const parseSource = (label: string): SourceRef | null => {
314 const url = /https:\/\/[^\s)]+/.exec(label)
315 if (url) return { url: url[0].replace(/[.,;]+$/, '') }
316 const ticket = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/.exec(label)
317 if (ticket) return { repo: ticket[1]!, issue: ticket[2]! }
318 const own = /(?:^|\s)#(\d+)\b/.exec(label)
319 if (own) return { issue: own[1]! }
320 const file = /(?:^|[\s(])((?:[\w.-]+\/)*[\w-][\w.-]*\.[a-z]{1,6})(?::(\d+))?\b/i.exec(label)
321 return file ? { path: file[1]!, line: file[2] } : null
322}
323types/index.d.ts 83 lines1export type Risk = 'sourced' | 'vague' | 'commitment' | 'risky'
2export type Answer = { text: string; risk: Risk; note?: string; source?: string }
3export type Question = {
4 question: string
5 relevance: number
6 confidence?: number
7 who?: string
8 /** Who on our side answers; only when the material names them. */
9 answerer?: string
10 why?: string
11 answers: Answer[]
12 /** Words to look up in the repository, from a live step; not shown. */
13 search?: string[]
14}
15/** Something I certainly did, said as is in any meeting. */
16export type Fact = { text: string; source?: string }
17export type SourceKind = 'meeting' | 'repo' | 'ticket' | 'doc' | 'calendar' | 'other'
18export type Source = { kind: SourceKind; label: string }
19export type Lang = 'auto' | 'de' | 'en'
20/** Which model answers live and how hard it thinks: speed against depth. */
21export type Quality = 'fast' | 'balanced' | 'thorough'
22export type QuestionSet = {
23 title: string
24 mode: string
25 generatedAt: string
26 /** Strings in sets stored before sources had a kind. */
27 sources: (Source | string)[]
28 questions: Question[]
29 /** Language the questions and answers were written in. */
30 language?: 'de' | 'en'
31 /** The folder they were prepared in; source paths count from it. */
32 folder?: string
33 /** What I worked on most recently, newest first; sets from before 0.11 have none. */
34 recent?: Fact[]
35}
36
37export type Live = {
38 isOn: boolean
39 status: string
40 meeting: string | null
41 lines: number
42 /** When the transcript last grew, for the status line. */
43 changedAt: number
44 asked: string[]
45 current: string | null
46 added: Question[]
47 /** Predictions; they stay until asked, new ones join at the end. */
48 next: Question[]
49 /** Answers a repository lookup found for a question asked now, by question text; drawn instead of the question's own. */
50 deep: Record<string, Answer[]>
51 /** Questions a repository lookup is still running for. */
52 looking: string[]
53 /** My latest commits in the folder, read while listening. */
54 recent: Fact[]
55}
56
57declare module 'claude-code' {
58 interface PluginState {
59 'great-question': {
60 set: QuestionSet | null
61 /** Question texts whose row is open; every row starts closed. */
62 open: string[]
63 /** The live rows as they stood when a row was opened; drawn instead of `live` until all rows close or the person takes the news. */
64 held: Pick<Live, 'next' | 'added'> | null
65 /** The second button row (language, sorting, view, export) is shown. */
66 more: boolean
67 /** A background agent is finding likely questions: when it started, for which occasion. */
68 preparing: { since: number; occasion: string; folder?: string } | null
69 /** Counts seconds while preparing, so the spinner turns. */
70 tick: number
71 /** Where sources point, by label; '' where nowhere. */
72 links: Record<string, string>
73 view: 'meeting' | 'practice'
74 sortBy: 'confidence' | 'likelihood'
75 lang: Lang
76 /** Model and effort of the live step, the repository answers and the ASKED NOW lookup. */
77 quality: Quality
78 live: Live
79 version: string
80 }
81 }
82}
83