Live bilingual (Hebrew/English) interview transcription: captures the interviewee's audio via WASAPI loopback and the interviewer's own microphone as two independent streams, transcribes both live, and writes a durable, speaker-labelled transcript to disk while the call is running.
On top of the transcript sits a live coach: during the call it suggests your next question, held to the Mom Test (ask about past behaviour, never pitch, never fish for compliments), and grounded in the survey and hypotheses you wrote beforehand. Afterwards it extracts claims into a local evidence corpus and shows where each hypothesis stands across interviews. Windows only.
- Read "Privacy, consent and data flows" below first. You are recording other people.
- Clone to a short path and install (Python 3.12+):
Put your
git clone https://github.com/Odiph/discovery-copilot C:\src\discovery-copilot cd C:\src\discovery-copilot py -3 -m venv .venv .venv\Scripts\python.exe -m pip install -r requirements.txt copy .env.example .envSONIOX_API_KEY(speech-to-text) andANTHROPIC_API_KEY(the coach) in.env. - Write what the interview is for:
.venv\Scripts\python.exe -m app authoropens a local page for the brief, survey and hypotheses. The coach judges every question against them. - Check both audio sides carry sound:
.venv\Scripts\python.exe -m app precheck. - Record: double-click
start-overlay.bat, or run.venv\Scripts\python.exe -m app start. Stop with Ctrl+C. - Afterwards:
renderfor a readable transcript,extractto pull claims into the corpus, andhubto review where each hypothesis stands. Every subcommand has--help; the sections below cover each in depth.
The rest of this README is the full operating manual, written from real use, so it goes into far more detail than a first run needs.
This tool records conversations with other people. Read this before the first call.
Consent is yours to get. This is not legal advice, and the rules differ by place:
- Some places require every party's consent, for example California (Penal Code 632). Software cannot tell which rule applies to a given call, so ask everyone, every time.
- In the EU (GDPR) a voice recording is personal data: you need a lawful basis, and before recording you must tell the interviewee who you are, why you record, who receives the data (the vendors below) and how long you keep it.
- In Israel a participant may record a conversation they take part in, and the Protection of Privacy Law (including Amendment 13, in force since August 2025) governs what you then do with the recording and transcript.
Say at the start of every call that you are recording, transcribing and using an AI assistant, and get agreement before you press Start. Nothing in this tool asks for or records consent on your behalf.
What leaves your machine, and to whom:
| Data | Sent to | When |
|---|---|---|
| Live audio of both sides | Soniox (speech-to-text) | Throughout every recorded call |
| Transcript text, your brief, survey, hypotheses and prior claims | Anthropic, or the OpenAI-compatible endpoint you configure (COACH_BASE_URL) |
Every coaching cycle during a call, and when you run extract or critique-questions |
Retained audio (--retain-audio, off by default) |
Nothing, unless you run the benchmark, which uploads it to the STT vendors it compares | Only when you run benchmark/ yourself |
Each vendor's own policy governs retention and training use, and they change,
so read them before recording anyone: Soniox security and
privacy, Anthropic API data
retention.
If you process other people's personal data, a data processing agreement with
each vendor is your responsibility. To avoid a vendor, set COACH_BASE_URL to
a model you run yourself; without ANTHROPIC_API_KEY and COACH_BASE_URL the
coach is simply off and only Soniox receives anything. Nothing is sent
anywhere else: no telemetry, no analytics.
What stays on your machine: transcripts, session metadata, extracted
claims and hypotheses, under sessions/ and projects/. Both are
git-ignored in full. They hold verbatim speech, so treat the folders as
confidential and delete sessions you no longer need. If someone asks you to
erase their data, delete their session directory and remove that session's
entries from the project's context/facts.json; there is no delete command
yet.
A note on the code comments. Many comments cite design notes such as
04-01-PLAN.md, 03-RESEARCH.md or STATE.md, and phase or gate numbers.
Those are the maintainer's private planning records and are not published;
the comments around each citation carry the reasoning that matters. The
benchmark's edge-tts dependency is LGPL-3.0, used as an unmodified library.
Who can reach the running app: during a call, a small page is served on
your local network (port 8765) so a phone on the same Wi-Fi can follow along.
Every route on it requires a random token generated fresh for that run, and it
is plain HTTP, so use it only on a network you trust. The console, the
authoring page and the hub listen on 127.0.0.1 only. The MCP server is
read-only and never returns quotes, transcripts or audio. See SECURITY.md.
Windows only, and not by accident: PyAudioWPatch ships win_amd64 wheels
only, and WASAPI loopback captures the host machine's default render
endpoint. There is deliberately no devcontainer — a container cannot record
this. Python 3.12+ with the py launcher (a standard python.org install).
py -3 -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
That is where the .venv\Scripts\python.exe named throughout this file comes
from. Every command below assumes it exists; a bare python is the system
interpreter, has none of these pinned packages, and will silently do something
else.
Keep the checkout path short (e.g. C:\src\discovery-copilot), or enable
Windows long paths. The anthropic package ships module names long enough that
a venv nested ~120 characters deep crosses Windows' 260-character limit, and
every import of the coach then fails with a ModuleNotFoundError that names a
file which is plainly there.
Then copy .env.example to .env in the repository root and fill it in —
never committed, never a CLI argument:
SONIOX_API_KEY=...
ANTHROPIC_API_KEY=...
SONIOX_API_KEY is the speech-to-text vendor: without it nothing transcribes.
ANTHROPIC_API_KEY is the coach's reasoning vendor: without it the transcript
still records perfectly and the live suggestions simply never appear.
From VS Code, none of this needs to be typed. Open the folder and run Ctrl+Shift+P → "Tasks: Run Task":
Setup: create venv and install— the two commands above. Run this first; nothing else works until it has succeeded.Overlay (floating recorder),Console,Start an interview,Precheck,Author,Hub,Render,Extract— one task per verb.Tests (offline)— the suite with every paid and hardware-bound test deselected.
Stop a running interview with Ctrl+C in the terminal — never VS Code's
terminate / trash-can button. That button is TerminateProcess: the process
dies where it stands, session.json never gets its closed_at, and the
transcript keeps whatever partial tail was mid-write. Outside VS Code,
double-click start-overlay.bat or open-console.bat; both are
workspace-relative and run from wherever the repository is checked out.
.venv\Scripts\python.exe -m app precheck
Play the call app's test audio (or wait for the interviewee to speak) and say something into the microphone while it runs. It prints one PASS/FAIL row per check — both devices resolve, both streams carry REAL audio above a level threshold (not merely "the device opened"), and the STT engine answers within its network timeout. Any FAIL exits non-zero and prints a specific remedy. Fix every FAIL before starting — a precheck that passes on a muted device is worse than no precheck at all.
Skip the engine-reachability row (e.g. to check audio only, with no network
call) with --skip-stt. Shorten or lengthen the listening window with
--seconds N (default 3).
Before a REAL interview, add --phone:
.venv\Scripts\python.exe -m app precheck --phone
This adds two more rows on top of the audio/engine checks above — the pinned web port is free, and this machine's LAN address resolves — plus a third, bounded row: it starts the server, shows the join URL, and waits (up to 30s by default) for a real phone to actually connect. PASS means a phone connected in time; FAIL means none did, and the row's remedy is the one-line triage rule from the hostile-Wi-Fi runbook below. It never hangs past its window. This is the one command that answers "will the phone actually work tonight" — run it, get everything green, then start the call.
.venv\Scripts\python.exe -m app start
Captures and transcribes both sides at once until you press Ctrl+C, or for a
fixed duration with --seconds N. To run only one side (debugging one half of
a broken setup), add --only interviewee or --only interviewer.
start also serves the live transcript to your phone from inside this same
process — one command, one process, no deployed service. It prints a join URL;
open that on the phone for the live view. (The QR is off by default — see "The join QR is off"
below.) The web port is pinned (8765 by
default — change it with APP_PORT if it's ever taken) so the printed URL
always matches what's actually listening. The first time you run start on this
machine, Windows Firewall may prompt to allow inbound connections for this
interpreter — accept it once, now, during a rehearsal, never live during a
real call.
One process per interview, with one exception. The overlay is a second process: it spawns
python -m app startas a child (app/overlay/session.py). The rule that holds everywhere is narrower — a web page never spawns a subprocess. The console proves it: pressing Start stops the page's own server and the same process runs the interview. The overlay is the one deliberate exception, and it earns it by being a real supervisor rather than a launcher — it holds the child's stdin control pipe, drains its stdout so it cannot deadlock, delivers CTRL_BREAK on stop, and checks the child's exit code instead of assuming a clean close. Nothing outlives what launched it.
Need to run start with no browser and no server at all (e.g. the
single-side trap tests, or debugging capture in isolation)? Add --no-serve.
Serving is not a separate mode you opt into — it's what start does by
default; --no-serve is the one flag that opts back out.
Pre-flight rules — not fixable in code, read them every time:
- Fix your call output device (Zoom/Meet/Teams) before pressing start. Do not change it mid-call — the loopback stream stays bound to whatever was default when it opened; plugging in headphones mid-call is detected and triggers one automatic re-open, but is still slower and riskier than not changing it at all.
- Disable sleep / set the power plan to "never sleep" for the call's duration.
- Loopback captures everything the machine plays, not just the call app — close other audio sources (music, notifications) before starting.
- Confirm the printed LAN address matches your Wi-Fi adapter — not a VPN or virtual adapter — before trusting the join URL (compare leading octets with your phone's own Wi-Fi IP).
- If a second monitor is in use, set your screen-share selection to that single window or display now — never "entire desktop" or "all displays".
Run this down in order, every time, before a real call — not during one:
- Author the brief, survey and any hypotheses (
python -m app author) well before the sixty-second countdown starts, never during it — see "Grounding the coach" below. Confirmcontext/brief.json,context/survey.jsonandcontext/hypotheses.jsonare the ones for this interview, not left over from a previous customer. Interview #1 always has an emptycontext/facts.json(nothing has been extracted yet) — with no facts extracted, the coach simply has no past interviews to draw on, which is not a fault. - Run
precheck --phoneand get every row green, including the phone actually connecting during the reachability window. - Confirm the printed LAN address matches your phone's own Wi-Fi address (not a VPN or virtual adapter — see the LAN-address row's own reminder).
- Open the printed join URL on the phone and confirm the live page loads now, before the call, not during it.
- If using a second monitor, set your screen-share selection to that single window or display now.
- Disable notifications (browser and OS) for the surface's origin — a notification banner can leak the surface even when nothing else is shared.
- Confirm the coach is live before the call, not during it:
start's own startup line readsCoach enabled: brief has N item(s), survey has M item(s).whenANTHROPIC_API_KEYis set and both files loaded. If it instead printsCoach disabled: ..., that is a decision to make now — capture and transcription still work either way, but a mid-call surprise about missing suggestions is what this step exists to prevent. - Start the session (
start), then start the call.
The one-line rule — the part to remember under pressure: slow or no response at all → assume isolation, hotspot first; fast refusal → firewall; loads but the IP looks wrong → adapter or VPN; loads fine but frozen → not a network problem at all, look at the laptop.
| Symptom | Likely cause | Fast diagnostic | Fix |
|---|---|---|---|
| Join URL opens, phone shows nothing, times out entirely, no error page | AP/client isolation — hotel, conference, co-working Wi-Fi blocking device-to-device traffic | This is the DEFAULT assumption on unfamiliar Wi-Fi. Do not debug further | Phone personal hotspot; the laptop joins the phone's hotspot |
Join URL gives "connection refused", ERR_CONNECTION_ABORTED, or any red error FAST, not a long timeout |
Windows Firewall blocking the port | Get-NetConnectionProfile — if NetworkCategory is Public, that is the answer. Do NOT go looking for a firewall prompt; on a Public network Windows blocks inbound silently and never prompts |
Make the network Private and add the allow rule — see below |
| Page never loads and the printed LAN IP looks wrong — e.g. a different subnet from the phone's own Wi-Fi address | Wrong adapter: a VPN or virtual adapter owns the default route | Compare the printed IP's leading octets with the phone's own Wi-Fi IP | Disconnect the VPN and re-run precheck |
| Phone connects and shows the page, but the transcript never updates | NOT a networking problem — capture or STT failure | Look at the laptop's terminal and the on-screen status banner, not the network | Follow the server-status banner, not this table |
The Public-network trap (the most common failure):
Windows classifies unfamiliar Wi-Fi as Public and then blocks inbound
connections silently — no prompt, ever. The laptop looks completely healthy
from the laptop: the server answers 200 on both 127.0.0.1 and its own LAN
address. Only the phone sees the failure, and it looks like a fast red error.
Diagnose and fix, from an Administrator PowerShell:
Get-NetConnectionProfile # NetworkCategory: Public <- the tell
Set-NetConnectionProfile -InterfaceAlias 'Wi-Fi' -NetworkCategory Private
New-NetFirewallRule -DisplayName 'Discovery Copilot phone surface (8765)' `
-Direction Inbound -Protocol TCP -LocalPort 8765 `
-Profile Private -Action AllowThe rule is scoped to Private on purpose. On a hotel, conference or
co-working network — exactly the networks Windows marks Public — the port
should stay shut and the hotspot procedure below is the intended answer. Do
not widen this to Public to make a venue work; use the hotspot.
The hotspot procedure (the default fix for the default problem):
- Turn on the phone's personal hotspot.
- Join the laptop to the phone's hotspot (not the other way around).
- Re-run
start. - Use the new join URL — the address changes because the network changed; that is exactly why the join surface is regenerated every run.
A tunnel is a manual last resort only, never part of the normal path — reach for the hotspot first, always.
- Share only the video-call application window. If full-screen sharing is unavoidable, pick the single monitor that does not have the surface open — never "entire desktop" or "all displays".
- Prefer the phone. Nothing on a phone screen can leak into a laptop screen-share by construction — that's why the phone is the design here, not a fallback.
- If you're using the second monitor instead, picking the correct single display share target is a checklist item, not something the app solves for you.
- Disable browser and OS notifications for the surface's origin — a notification banner is a leak vector independent of what you actually share. The browser window title is already generic; don't rename the tab.
Two secrets to manage, both the same discipline: the environment or a local
.env file, never committed, never a CLI argument.
SONIOX_API_KEY(andASSEMBLYAI_API_KEYif used) — the STT vendor.ANTHROPIC_API_KEY— the coach's reasoning vendor. If it's absent,startprintsCoach disabled: ...and runs anyway — capture and transcription are completely unaffected; you just get no suggestions. See "Grounding the coach" below.
The coach takes its client by injection and speaks two dialects, so any OpenAI-compatible endpoint works — a local runtime (Ollama, vLLM, llama.cpp) or a hosted provider (OpenRouter, Groq, Together, DeepInfra, Fireworks). Three variables, all read from the environment like every other secret:
COACH_BASE_URL=https://openrouter.ai/api/v1
COACH_API_KEY=...
COACH_MODEL=google/gemini-2.5-flash
COACH_BASE_URL wins outright. When it is set, ANTHROPIC_API_KEY is not
consulted at all — which is deliberate, and is also why every test that
exercises the no-credential degrade path has to blank both routes (see
app/tests/conftest.py, which does it for the whole suite after this bit them
three separate times).
COACH_MODEL is not optional here. The default is a dated Anthropic model
id and means nothing to another provider, so pointing the coach elsewhere
without naming a model sends that endpoint an id it has never heard of.
EXTRACT_MODEL buys quality with latency nobody is waiting on. The live
coach answers inside a ~8s cycle while an interview is happening, so its model
is chosen under a latency bound. Extraction runs after the interview ends —
so a slow, strong model there costs money and nothing else. It is the one
place in this system where quality is free.
EXTRACT_MODEL=anthropic/claude-opus-4-5
Unset, it falls back to COACH_MODEL and changes nothing. It selects a
model, not a provider: there is one client construction site, so whatever
you name has to be served by the same COACH_BASE_URL endpoint.
Worth setting, because the extraction model is measurably the weak link: it
labels merely-implied claims evidence, at 3/18 on a hand-adjudicated real
interview. app/coach/extract.py's own docstring carries the measurement and
the two failure modes.
Choosing a model is a measurement, not a preference. benchmark/eval/judge_critic.py
runs a labelled question set through the real critique path and reports whether
the model can hold a forced tool call against the real rubric at all. Run it
before trusting a model with an interview — it costs a fraction of a cent:
.venv/Scripts/python.exe benchmark/eval/judge_critic.py
Measured over 3 runs of 17 labelled cases each. judged is the gate
that matters: a model that cannot emit the tool call is not a cheap coach, it
is not a coach.
| model | judged | rules caught | band flips | s/call | $/interview |
|---|---|---|---|---|---|
google/gemini-2.5-flash |
17/17 | 5/5 | 0 | 2.85 | 1.87 |
anthropic/claude-haiku-4.5 |
15.7/17 | 4.7/5 | 4 | 4.95 | 5.82 |
mistralai/mistral-nemo |
7/17 | 1.3/5 | 13 | 4.29 | 0.10 |
Five further models — openai/gpt-5-nano, openai/gpt-oss-120b,
qwen/qwen3.5-flash, z-ai/glm-4.7-flash and both local qwen2.5 sizes —
failed the tool-fidelity gate outright and are not candidates at any price.
Two caveats that keep those numbers honest. Both $/interview figures assume
no prompt cache, because the OpenAI-compatible seam drops cache_control
(see app/coach/openai_seam.py) and prefill is roughly 8× without one; Haiku
direct through the Anthropic SDK, where caching does apply, measured
$1.05. And this compares Haiku through OpenRouter, not Haiku direct — the
parse errors and band flips in that row may belong to the translation rather
than to the model.
The join token embedded in the URL is not a secret to manage — it's generated fresh in memory every run, never written anywhere, and dies the moment that run ends. There's nothing to store or protect about it beyond not leaving old join URLs lying around.
A project is a nested label — a product, a feature within it, a hypothesis
within that. Nesting is directory nesting, so a project path looks like
acme/onboarding/activation-delay and lives under projects/:
projects/acme/onboarding/
context/ brief.json, survey.json, hypotheses.json, facts.json
sessions/ interviews filed at THIS node
Pass --project to start, render, author or extract:
python -m app author --project acme/onboarding
python -m app start --project acme/onboarding
python -m app extract --project acme/onboarding 20260810T143000
precheck deliberately does not accept it — it touches neither directory,
so a project there would be an argument it silently ignored.
Omit --project and nothing changes: the root context/ and sessions/
are used, exactly as before projects existed.
All of projects/ is git-ignored, blanket. That rule has to stay blanket:
projects/* with a !projects/*/context/*.example.json negation looks like it
would let format samples live in the tree, but git never descends into an
excluded directory, so the negation is never consulted. The samples stay in the
root context/, which is what lets this rule be total. A test asserts it.
By default the coach is grounded in the filing node alone. --read-from adds
another node's facts and hypotheses, read-only, and is repeatable:
python -m app start --project acme/onboarding --read-from acme/billing
The rules, all enforced rather than documented:
- Same hierarchy only. A node under a different root is refused. Knowledge is shared within one product, never across two customers.
- Reads merge; writes never do. Extraction always writes to the filing node. A source node's files are left byte-identical.
- The brief and survey always come from
--projectalone. They are what this interview is about; merging another node's survey would quietly change the questions you ask in the room. - Facts are pooled, then selected once, so the retrieved-fact cap still means what it says — three sources do not put three times the facts into the brief.
- Merged fact ids are qualified with the node they came from
(
fact:acme/billing:<id>). Session ids are only unique within one root, so two projects can mint the same one; unqualified, two customers' facts would read as a single provenance chain.
Every rejection is an error, never a silent skip — a dropped source would mean the coach ran on less grounding than you selected while reporting success, and you would blame the model for the thinner suggestions.
While a projected session runs, the overlay shows a small strip naming the
project and its extra sources, so you can always see which knowledge is in
play. It is hidden entirely for an unprojected run. Note it renders the node
paths on a screen that may be sitting in a cafe: if you name a node after a
customer, that name is on the screen. The session's session.json records the
same two fields for later.
python -m app console
or double-click open-console.bat. A loopback-only page opens showing the
project tree; select a node and you see its survey, its hypotheses with their
states, how many facts it has accumulated, and its past interviews. Tick any
in-branch nodes to also draw on, then press Start.
The console and the interview are one process. Pressing Start stops the page's server and the same process runs the interview — a web page never spawns a subprocess (see "Starting a call" above for the overlay, the one process that does). So leave the terminal open and read it once you have pressed Start; that is where the interview reports from. Ctrl+C before choosing cancels without starting anything.
The console only reads. No route it exposes changes anything on disk, and
it shows a fact COUNT rather than any fact — a fact carries a previous
customer's verbatim speech, and this is a browser page. Authoring stays where
it was, on python -m app author.
It carries the same two guards as the authoring page (loopback bind, Host
header, JSON Content-Type) because the loopback bind stops the LAN but not
your own browser. Here the consequence of getting that wrong is worse than the
authoring hole those guards were added for: a cross-origin POST would choose
which customer's knowledge grounds your next interview.
One limitation, stated rather than discovered: DEFAULT_APP_PORT (8765) and
DEFAULT_AUTHOR_PORT (8766) are fixed and never auto-chosen, so two projects
cannot run at once. That is acceptable given the one-PyAudio-instance rule,
which already forbids two concurrent interviews on one machine.
By default create_join does not draw the terminal QR or write
join-qr.png. The rest of the join surface is unaffected: the token is still
fresh per run, the LAN IP is still resolved, and the join URL is still printed,
so a phone can still join by typing it.
Re-enable with APP_EMIT_QR=1. The renderers were left in place rather than
deleted, so that is the only change needed.
Keep rehearsals and test recordings in their own project, for example
projects/_eval/, and declare it {"corpus": "demo"} in its project.json
so its claims are never mistaken for customer evidence:
python -m app render --project _eval <session-id>
A bare python -m app start with nothing in the root context/ tells you to
author a brief first; that is the default behaviour, not a fault.
The authoring page (python -m app author) now carries three things, all
under context/ and all git-ignored (the brief can name a real customer —
this is the most sensitive plaintext this repository ever produces, and
hypotheses are right behind it: each one names a bet you're making about that
same customer):
context/brief.json— what you know or suspect about the interviewee, as a list of items. Each one is labelled"provenance": "evidence"(you were told this, or read it somewhere) or"provenance": "inference"(you're guessing). There's no default — a missing or misspelled provenance value fails to load rather than silently becoming "evidence".context/survey.json— the ordered list of questions you plan to ask. The coach tracks which ones the interviewer (you) actually asked, from your own microphone stream — never from a suggestion shown, whether or not you followed it.context/hypotheses.json— what you believe about this customer, stated so it can be proven wrong. Every hypothesis needs a falsification condition — what evidence would make it FALSE — and the page refuses to save one without it: a hypothesis nobody could ever disprove gives the coach nothing to target when it steers toward the question that would settle it. A new hypothesis always starts"state": "untested"; the state is yours to change as evidence comes in — nothing in this codebase changes it for you yet. Today, hypotheses are read bypython -m app extractfor tagging only (each extracted fact may reference the hypothesis it bears on); nothing else reads or writes them.
Copy the shipped format samples to get started:
copy context\brief.example.json context\brief.json
copy context\survey.example.json context\survey.json
copy context\hypotheses.example.json context\hypotheses.json
The brief and survey are read once, at start, and copied into that
session's own metadata — editing them mid-call never re-grounds a session
already running. start fails loudly, before anything else happens, if
either file is missing or malformed. start warns (but proceeds) if
either file predates your previous session — a stale brief from a different
customer is a realistic and expensive mistake, but only you can tell "stale"
from "deliberately reused". Hypotheses are read separately, by
python -m app extract, at extraction time — see "Extracting facts" below.
If ANTHROPIC_API_KEY is absent, none of this is even read — the coach is
disabled and the session records a transcript only.
Every run creates a new timestamped directory under sessions/ (gitignored —
confirmed with git check-ignore, never by reading .gitignore) containing:
-
transcript.jsonl— one JSON record per line, append-only,fsync'd after every write so a crash never loses what was already captured. Segment records carryspeaker(intervieweeorinterviewer),text, timestamps, and a measured latency. Health/trap events (idle, recovered, lagging, dropped, device-changed, dead, stream-failure, ...) interleave in the same file, in the order they happened. -
session.json— provenance: both device names, both native sample rates as reported by the driver, the resolved engine, start/close times, whether audio was retained (audio_retained), a per-speaker latency summary, and — when there is one —participant, who was interviewed.participantis optional and its ABSENCE is the normal state: pass--participant "Dana (ops lead)"tostart, or fill it in afterwards on the hub, which also clears it. The key is omitted entirely when nobody is named rather than written asnull. It exists because per-interview counting counts sessions, which is the same as counting people only while nobody is re-interviewed — the label does not change the count, it makes the count checkable by a human. -
transcript.md— only afterpython -m app renderis run (see below). -
interviewee.wav/interviewer.wav— only if--retain-audiowas passed (see below). Already covered by the existing*.wavignore rule.
.venv\Scripts\python.exe -m app render <session-id-or-path>
Writes transcript.md into the session directory: both speakers interleaved
in strict time order, merged into paragraphs per speaker turn, with health
events (idle, device-changed, ...) shown inline at the point they happened,
and a per-speaker latency summary (count, median, p95) at the end. Segment
text is reproduced exactly as the engine emitted it -- mixed Hebrew/English
lines are never reordered or "fixed". Works on a session that crashed or is
still running; it says so in the output rather than failing.
This same document is also where every coach decision gets audited. Inline, in the same session-time order as the transcript itself:
- Every suggestion the coach ever produced -- shown, ranked down, and
dropped -- with its move type (
advance/deepen/fill_gap/test_hypothesis), its band, its five named per-rule flags, and its rationale. Read the dropped ones: a high drop rate concentrated on one flag means the critic rubric is over-firing on real calls, and that is the single most useful tuning signal the post-call document gives you. - Which past-interview facts grounded a suggestion.
A
grounded in:line under a suggestion prints the fact's claim and its verbatim quote from that earlier interview -- this document is the ONLY place in the whole tool a past interview's exact words are ever shown; they never reach the phone or the live call itself. If a suggestion cites an id that resolves to nothing, that's normal, not corruption: fact ids are re-minted every time you re-runextract, so an id from before your last re-extraction won't match anymore. - A proposed hypothesis-state change. A line reading
PROPOSED (not applied): hypothesis ... -> '...'means the coach thinks the evidence points that way -- nothing has changed yet. The hypothesis set changes only when you change it, on the authoring page (python -m app author) -- there is no other accept surface, and reading this line is the whole workflow: decide whether you agree, then go set it yourself. - The FULL coverage transition history -- every survey question the coach marked as asked, and what interviewer sentence it matched against; every segment it considered and found no match for too. This is where you check whether the coach quietly stopped tracking a question: a false-positive match silently removes that survey item from attention for the rest of the interview, and reading "matched X against Y" side by side is the only place that mistake is ever catchable after the fact.
- A closing
## Coaching summary-- cycles completed, suggestions shown, candidates dropped broken down by which rule fired, coverage counts, how many suggestions were grounded in a past-interview fact (and how many distinct facts were cited), how many hypothesis-state changes were proposed (with the same not-applied reminder), and the coaching latency figures (session-start grounding, first cycle, and remaining cycles, reported separately). Reports all-zero, without failing, for a session where the coach never ran -- including every session recorded before this phase existed.
.venv\Scripts\python.exe -m app extract <session-id-or-path>
The one command to run after an interview. Reads the session's transcript
and writes cited, provenance-tagged facts into context/facts.json -- the
same directory the brief and survey live in, under the identical
git-ignore rule. context/facts.json and context/hypotheses.json are
never committed, on purpose: they carry verbatim customer speech, exactly
like context/brief.json already does. Nothing here changes that.
Safe to re-run. Running extract again on the SAME session replaces
that session's facts with the new run's -- it never doubles the corpus. This
matters in practice: a prompt fix, a model swap, or just re-running because
something looked off should never leave two versions of one interview's
facts sitting side by side, reading as if two different customers said the
same thing. Every OTHER session's facts are left completely untouched by a
re-run.
A non-zero exit means at least one returned claim was rejected -- the run still writes every fact that WAS accepted; only the rejected ones are dropped. Look at the printed report, not just the exit code: each rejection names the claim and one of two reasons, and the two point at different problems --
unresolved-citation-- the model cited a segment that doesn't exist in this session's transcript (a miscounted or hallucinatedseq).unlocatable-quote-- the claim was labelledevidence, but its quoted text isn't really, verbatim, in the segment it cites. Either the model paraphrased instead of quoting, or the claim is really an inference that got mislabelled.
If extraction refuses to run at all (exits non-zero before printing
any per-claim report), the EXISTING corpus at context/facts.json failed
to parse -- the run stops before the vendor is ever called, and the message
names the file (and, where possible, the offending item). Nothing was
overwritten or lost; fix or restore that file, then re-run.
If you see WARNING: session ... has no closed_at marker, the session
you pointed at was never closed -- extraction still runs; this is a
warning, not a refusal. Only you can tell a deliberate mid-interview sanity
check from a forgotten crash, so the fact set is written either way, but
treat it as possibly incomplete if the interview is genuinely still going.
Roughly what one extraction costs: every run prints the accepted and rejected counts plus the call's own token usage, when the response reports it. This is a per-interview batch cost, not a per-utterance one -- check the printed numbers against a few real interviews rather than trusting a one-line estimate; that is exactly what these numbers are for.
All take --project PATH like the rest, and none calls a model unless noted.
summary <session>: what one interview produced: how many claims, how many were stated versus inferred, and which hypotheses they bore on.standing [session]: where every hypothesis stands across all extracted interviews. Name a session to also see what that one interview moved.critique-questions <session>: bands YOUR OWN questions from the interview against the same Mom Test rubric the coach is held to, and writesquestions_critique.jsoninto the session. This one calls the reasoning vendor.--min-wordssets the shortest turn worth judging.export --to backup.zip [--verify]: writes a checksummed copy of the project's irreplaceable data (transcripts, facts, hypotheses) to a zip. It refuses a destination git can see, so a backup can never be committed by accident;--verifyre-reads every checksum after writing.
.venv\Scripts\python.exe -m app start --retain-audio
Off by default. Add --retain-audio to also write one 16 kHz mono WAV
per speaker (interviewee.wav, interviewer.wav) alongside the transcript --
this is exactly the two-stream input the STT benchmark under benchmark/
needs. Point the existing benchmark harness at the retained pair; no new benchmark tooling
is needed.
Use --retain-audio for a REHEARSAL or mock interview ONLY -- never a real
customer interview. The benchmark this audio feeds uploads to two
third-party STT vendors you may not have agreed to share customer audio
with. A retained WAV pair from a real call is one command away from shipping
customer audio to them. session.json's audio_retained field always records
whether a given session actually retained audio, so this is never left to
guesswork after the fact.
One environment variable, read once at startup — nothing else in the capture or session code ever names a vendor:
set STT_ENGINE=soniox REM the funded, live default
set STT_ENGINE=fake REM offline, deterministic, no credentials -- for local testing
A --engine flag on both start and precheck overrides the environment
variable for a single run. Put SONIOX_API_KEY (and, if used,
ASSEMBLYAI_API_KEY) in a local .env file — never in source control.
Extraction failures aren't in the table below — they're printed
CLI-side, not written as session events, and are covered in full under
"Extracting facts" above: a non-zero exit, an unresolved-citation
vs. unlocatable-quote rejection, a refusal to run at all, and the unclosed-
session warning are each a different condition that looks like "extraction
failed" from the terminal — that section is where you tell them apart.
Every health/trap condition is a named event record in transcript.jsonl
(and inline in transcript.md once rendered) — look for these names:
| Event | Meaning | What to do |
|---|---|---|
idle |
That stream has produced only zero-filled (synthesized) frames for a few seconds | Normal during a pause. If it never clears: check the interviewee's call app isn't muted, or that you're speaking into the right microphone |
recovered |
Real audio resumed after an idle period |
Informational — no action needed |
lagging |
The STT engine can't keep up; the capture queue is backing up | Usually a slow local engine or a network stall. Capture is never blocked — but segments may be delayed |
dropped |
The queue overflowed and the oldest frame was discarded (carries a running count) | A sustained lagging condition turned into real data loss — switch to a faster engine (--engine soniox) or investigate the network |
device-changed |
The Windows default output device changed mid-call (e.g. headphones plugged in) | One automatic re-open is attempted; check the next event for the outcome |
reopened |
The automatic re-open after a device-changed succeeded |
Informational |
dead |
A stream's device changed and the one re-open attempt failed | That side has stopped capturing. Restart python -m app start |
stream-failure |
An unhandled error inside one stream's worker thread | That side stopped; the other side and the session file are unaffected. Check the event's error_type/message |
keepalive-warning |
The silent keep-alive render stream (second layer against the silent-loopback trap) failed to open | Not fatal — the pacer's own non-blocking read is still the primary mitigation |
audio-retention-error |
--retain-audio was set but writing a WAV failed (e.g. disk full) |
Retention is disabled for the rest of that session; capture and transcription are unaffected |
session-resumed |
python -m app start was pointed at an existing session directory and continued it rather than starting fresh |
Informational — confirms a crash recovery picked up where it left off |
coverage-asked |
The coach decided your own words asked a survey question — carries which one, never the words themselves | Informational — the full history (matches AND near-misses) is what coverage-considered below is for |
coverage-considered |
A finalized interviewer segment was checked against the survey and matched nothing | Informational — normal for most of what you say |
coverage-queue-drop |
The coach's coverage queue was full (a sustained vendor slowdown) and the oldest pending check was dropped | Not fatal — a fresh check happens on your very next sentence |
reasoning-timeout |
A coach vendor call (stage: coverage, generate or critique) timed out or errored |
Suggestions may be briefly missing; the transcript is completely unaffected. A single one is normal degradation — a SUSTAINED run of them (three or more in a row) is what raises the phone's own "suggestions unavailable" banner |
coach-cycle-skipped |
The interviewee kept talking before the previous suggestion cycle finished, so the queued-but-not-yet-started cycle was replaced by the newer one | Informational — this is "one cycle at a time, replace never queue" working as designed, not data loss |
"The suggestion region on the phone is quiet" is not one condition — it is three, and only one of them is a problem:
| What you see on the phone | What it means | What to do |
|---|---|---|
| A calm banner reading "Suggestions unavailable — transcript continues" | The coach's reasoning layer failed a SUSTAINED run of cycles | The transcript is unaffected. Check your network and ANTHROPIC_API_KEY; this is never the "Transcription stopped on the laptop" banner, which is a different, more urgent condition |
| The region is quiet, no banner at all, everything else looks healthy | The coach had nothing worth suggesting this cycle | Nothing to do — this is a normal state, not a fault |
| One suggestion, no alternatives underneath it | Normal, complete state — never a partial or half-loaded render | Nothing to do |
| The coach never suggests a hypothesis-testing question | Two ordinary causes, both normal: (1) no hypotheses were authored for this interview — check context/hypotheses.json on the authoring page; (2) hypotheses exist, but nothing extracted yet is relevant enough to test one — check the post-call document's dropped candidates for whether test_hypothesis candidates were proposed and dropped, or never proposed at all |
Author at least one hypothesis before the call if you want this move available; after the call, read the coaching summary's drop breakdown before assuming something is broken |
If you cannot immediately tell which of these three you are looking at, that is itself worth noting after the call — see "Reading the transcript after the call" below for where the coverage log settles any doubt about what the coach actually did.
python -m app.mcp serves this machine's interview corpus to your other AI
agents over the Model Context Protocol — JSON-RPC 2.0 on stdio, hand-rolled
against the standard library, no new dependency.
What it will and will not hand over. It is READ-ONLY: seven tools, none of
which writes a byte. It exposes extracted CLAIMS, hypothesis standings and
interview metadata — never quoted_text, never a transcript segment, never
audio. This is the only surface in this project that hands the corpus to a
process you did not write, so the quote boundary is enforced structurally
(every projection is written out field by field) and pinned by a test.
Never put a key in an MCP config. These files get committed. This server
needs no credential of any kind — it reads local files — and
app/tests/test_mcp_harness.py fails the build if .mcp.json ever grows a
key-shaped value, or even a setting merely named like one.
Nothing to do — .mcp.json at the repository root registers it, and Claude
Code offers to enable it the first time you open this folder:
{
"mcpServers": {
"discovery-copilot": {
"type": "stdio",
"command": ".venv/Scripts/python.exe",
"args": ["-m", "app.mcp"]
}
}
}The interpreter path is relative on purpose, so it is the venv of
whichever checkout you opened — including a git worktree. Never replace it
with an absolute path: that works in exactly one checkout on one machine.
Claude Code launches the server with the project directory as its cwd, which
is also what puts the repository root on sys.path so -m app.mcp can import
app.
Check it with /mcp inside Claude Code; the server should list as
discovery-copilot with seven tools.
Claude Desktop reads ONE global config instead of a per-repository file, and
it launches servers from an arbitrary working directory. So the entry needs an
absolute interpreter path and an explicit cwd — without the cwd,
python -m app.mcp cannot import app and the server dies at startup with an
import error you will only see in Claude Desktop's own log.
Edit %APPDATA%\Claude\claude_desktop_config.json (Windows) and add:
{
"mcpServers": {
"discovery-copilot": {
"command": "C:\\path\\to\\discovery-copilot\\.venv\\Scripts\\python.exe",
"args": ["-m", "app.mcp"],
"cwd": "C:\\path\\to\\discovery-copilot"
}
}
}Replace both paths with your own checkout, restart Claude Desktop, and the tools appear under the connector menu.
.mcp.json reaches the server only from inside this checkout. A product
repository opened in Claude Code gets it from the plugin in
integrations/claude-code/, which teaches that repository three things: the
tools (the server registration), which project it answers to
(.discovery-copilot.json, written by /link-discovery), and how to reason with
the evidence (the discovery-evidence skill: demo and undeclared claims are
never findings, counts are per interview, gaps become proposals rather than
assumptions).
Once per machine:
setx DISCOVERY_COPILOT_HOME "C:\path\to\discovery-copilot"
claude plugin marketplace add "C:\path\to\discovery-copilot\integrations\claude-code"
claude plugin install discovery@discovery-copilot-local
Then restart Claude Code, and in each product repository run
/discovery:link-discovery <project>. After that, /discovery:evidence <question> answers from the interviews with citations, and
/discovery:interview-gap drafts hypotheses and survey questions into the
product repository's discovery-proposals.md, which you enter here through
author. Nothing in the plugin writes to this checkout.
- The checkout is named only through
DISCOVERY_COPILOT_HOME. Claude Code copies an installed plugin into its own cache, so no relative path can reach this checkout, and an absolute one would be theopen-console.batmistake. The same copy means an edit to the plugin reaches nobody untilclaude plugin marketplace update discovery-copilot-localand a restart. /link-discoveryalso writes deny rules into the product repository's.claude/settings.local.jsonfor this checkout'sprojects/,sessions/andcontext/. Measured, not assumed: without them, a session in the product repository read a transcript here directly, around everything the MCP server withholds. With them, the same read was refused. Claude Code asks before writing its own settings file; approve it.app/tests/test_claude_plugin.pylaunches the registration from a foreign directory and fails if the skill or commands name a tool the server lacks.
| Tool | Answers |
|---|---|
list_projects |
every project node on this machine — start here |
get_survey |
the ordered questions you set out to ask |
get_hypotheses |
statements, falsifiers, states, decision criteria |
list_interviews |
which interviews exist, and whether each closed cleanly |
get_facts |
the extracted claims and which way each one bears |
get_standing |
where each hypothesis stands, counted per INTERVIEW |
get_interview_summary |
what one interview did, deterministically |
get_hypotheses, list_interviews, get_facts, get_standing and
get_interview_summary take a scope:
"node"(the default) reads only the project you named."subtree"also reads every project nested underneath it and merges the results.
subtree is opt-in and never implicit, because it is a cross-project read —
the same rule the console's "draw on another project's knowledge" follows:
reads may merge, within one hierarchy, on explicit request; writes never
merge. A sub-tree never climbs to a parent, never reaches a sibling, and
never reaches another hierarchy — asking for acme does not pull in
acme-corp.
Every item in a merged answer carries the project node it came from, and
every count in a merged answer is per-interview, never per-fact: one
interviewee who made the same point five times moves supporting_interviews
by one. supporting_facts is reported alongside it, so you can see both
numbers and never mistake one for the other. If one node in the sub-tree has a
malformed artifact, only that node degrades — its path is named in errors
and the rest of the answer still arrives.
.venv\Scripts\python.exe -m pytest app/tests benchmark/tests -q -m "not live and not hardware and not live_reasoning"
Add -m hardware (needs a working default output device and microphone, with
OS microphone access granted), -m live (needs a funded STT API key), or
-m live_reasoning (needs a funded ANTHROPIC_API_KEY and makes a paid call
to the reasoning vendor — a few cents) to run the tests deselected above.