SLOPSHOPPER

great-question

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

newpaneguardcommandtoastprompt
v0.12.1MITupdated 2026-10-16jsc2304/great-question
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · great-question
│ ┃ Great Question ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ No prepared questions yet │ great-question │ │ ┃ Find likely questions or listen live · ⏺ Read(src/auth.ts) │ Great Question: finding likely questions … │ │ ┃ surest first ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ ● say it ○ weak ▲ only if covered ✖ don't ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Listen live ] [ Find likely questions ] [ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Where do you want to start? │ │ ┃ │ │ › /great-question-prep │ ┃ │ ▸ Likely questions about app │ ⎿ great-question: Finding likely questions in the background (no o │ ┃ │ ▸ Prepare my next meeting │ │ ┃ │ ▸ Prepare today's daily │ │ ┃ │ │ │ ┃ │ Or just Listen live: questions then come │ │ ┃ │ from the live transcript only, answers │ │ ┃ │ from this repository. │ │ ┃ ╰──────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Great Question
No prepared questions yet Find likely questions or listen live · surest first ● say it ○ weak ▲ only if covered ✖ don't say [ Listen live ] [ Find likely questions ] [ More ▸ ] ╭──────────────────────────────────────────────────────────╮ │ Where do you want to start? │ │ │ │ ▸ Likely questions about app │ │ ▸ Prepare my next meeting │ │ ▸ Prepare today's daily │ │ │ │ Or just Listen live: questions then come from the live │ │ transcript only, answers from this repository. │ ╰──────────────────────────────────────────────────────────╯
README

Great Question

Know the question before it is asked, and what you can safely say.

Release License: MIT Claude Code plugin

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.

  • Prepare any meeting with one command: likely questions, who asks them and why, and answers with their sources. No Wispr Flow needed.
  • Answer safely: every answer is marked say it, weak, only if covered or don't say, and the panel always offers something that is true and promises nothing, from what you worked on last.
  • Predict questions live during the meeting with Wispr Flow (macOS): the question just asked jumps to the top with its answers, looked up in your repository, and the questions likely to come next appear with their answers before they are asked.

Quick start

/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:

MarkMeaning
● SAY IT · sourcedwell founded, with a source
○ WEAK · vaguetrue and binds us to nothing, but shallow
▲ ONLY IF COVERED · commitmentbinds us (date, price, scope); say it only when the documents back it
✖ DON'T SAY · riskyunsupported, attackable or contradicting the documents

What it looks like

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:

The Great Question panel: safe to say from my recent work, questions grouped by answer confidence, one opened with a sourced answer and a warning what not to say

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:

An unsure question with three kinds of answer: weak, only if covered, don't say

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:

Listening: the question asked now with its answers, my recent work, two likely next questions and two questions asked but not prepared

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

A held live list: the question asked now, my recent work, a button to take three new questions and the open question that holds the list

Screenshots show a fictional pitch for a fictional company; no real meeting was recorded.

Requirements

  • Claude Code in the terminal or the desktop app, tested with version 2.1.296.
  • Wispr Flow for the full feature set. Without Wispr Flow, Great Question prepares questions from the repository, tickets and other connected sources. With Wispr Flow you also get:
  • Listening along: while a recording runs, it reads the live transcript of the Wispr Flow app (Meeting Recorder, macOS) and suggests questions from the last two minutes.
  • Meetings and transcripts during preparation: through the Wispr Flow connector in Claude Code, /great-question-prep finds the upcoming meeting with its attendees and earlier conversations with the other side.

Installation

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.

Updating

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).

Usage

CommandEffect
/great-questionopens 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:

  • Listen live: reads the running Wispr Flow recording every 20 seconds and shows ASKED NOW, SAFE TO SAY · MY RECENT WORK, LIKELY NEXT and ASKED IN THE MEETING, NOT PREPARED. Switching it off only pauses.
  • Find likely questions: like /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.
  • Open all and Close all: open or close every visible question. Close all shows as soon as one question is open and closes them all at once.
  • More: shows the less frequent settings and actions:
  • Language: Auto / DE / EN of questions and answers. Auto uses the language the meeting is held in (from the invitation, slides and earlier transcripts when generating, from the conversation when listening). Applies to everything generated from then on, live included; nothing already shown is translated. Notes and reasons are in English.
  • Model: who answers while listening and how hard it thinks: Fast (Haiku), Balanced (Sonnet, low effort) or Thorough (Sonnet, high effort). Applies to the live step, the answers from the repository and the model of the ASKED NOW lookup; the background preparation keeps the session's model. Slower levels answer later: a step still running when the next is due is skipped.
  • Sort: by answer confidence (safest to answer) or by likelihood (most likely asked).
  • Meeting / Practice: Meeting shows the strongest first, Practice turns the order round and puts the weakest questions on top.
  • Export: writes the panel as Markdown to question-sets/YYYY-MM-DD-HHMM-<title>.md in the project folder.
  • Clear (only while paused): deletes the live results.

While listening, the panel follows the questions you have to answer:

  • ASKED NOW, LIKELY NEXT and ASKED IN THE MEETING, NOT PREPARED hold questions asked of you or of everyone, or about something you work on. Questions to someone else about their own work stay out. In a video call your voice is the microphone; your git user name helps the model spot questions addressed to you by name.
  • SAFE TO SAY · MY RECENT WORK lists what you worked on last, true whatever is asked: from the preparation (commits, pull requests, tickets or meetings, each with its source), or, when the preparation has none, your last five commits in the folder (author = your git email, merges left out).
  • Live answers come safest first. A closed question shows the one that binds you to nothing before a promise; a commitment is offered only when the question asks for a date, an owner or a scope, and never with a date or name nobody gave.

While listening, answers come from the repository too:

  • For every new question from the call, the mod searches the session's repository for its key words (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.
  • For the question under ASKED NOW, unless the prepared answers already rest on sources, a read-only subagent looks it up in more depth. While it runs, the box says checking the repository …; its answers then replace the quick ones, marked from the repository.
  • Only the repository is searched live. Slides in Drive, Jira and other connected sources come in through the preparation only.

While listening, the list only grows, it is never rebuilt:

  • New questions arrive closed at the end of their section and show their best answer in one line. A question keeps its wording even when the model rephrases it slightly.
  • An asked question stays in place, marked ✓. Only when paused does it move to ALREADY ASKED.
  • While a question is open, the live sections stand still. New ones collect behind ↻ take N new and arrive by themselves once all questions are closed. ASKED NOW always stays live.

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.

Data and confidentiality

  • Record only with consent. Wispr Flow records through this computer's microphone and system audio; no bot appears in the video call, so the others cannot see that it is being transcribed. Get the consent of everyone in the meeting before recording. In Germany, recording the privately spoken word without consent is a criminal offence (§ 201 StGB).
  • Transcription by Wispr Flow. Recording happens locally, transcription happens in Wispr Flow's cloud (Wispr: on-device recording, as of 14.10.2026). Great Question reads the finished transcript from the Wispr Flow app's local file.
  • Prepared questions are stored locally per project folder. They stay on the computer.
  • Listening along sends the last two minutes of the transcript, the prepared questions, the repository lines found for new questions, your git user name and your last five commit subjects to a Claude model through the same access as the session: Haiku by default, Sonnet when chosen under More → Model. A lookup for the question asked now runs as a read-only subagent with the same model in the session's folder. Speaker names from Wispr Flow are not sent; names mentioned in the conversation are in the text.
  • Exports with live results contain questions from the conversation, names included. In projects with confidential content, add question-sets/ to .gitignore or review it before committing.

Development

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 ..

FileContents
hooks/register.tsxcommands, the question-set tool, panel, export
hooks/live.tslistening along: read the transcript, fold in the model's reply
types/index.d.tsdata types and the panel's state
tests/tests for panel, export and listening along

Feedback

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.

License

MIT, see LICENSE.

Source 3 files
hooks/register.tsx 1029 lines
1import { 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}
1029
hooks/live.ts 323 lines
1import 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}
323
types/index.d.ts 83 lines
1export 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